referenzen auf "credits" entfernt. Kamera-Seite schicker gemacht
This commit is contained in:
@@ -33,6 +33,7 @@ use Illuminate\Support\Facades\Cache;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -1618,6 +1619,10 @@ class EventPublicController extends BaseController
|
|||||||
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
||||||
$limit = max(1, min(50, (int) $request->integer('limit', 35)));
|
$limit = max(1, min(50, (int) $request->integer('limit', 35)));
|
||||||
|
|
||||||
|
if (! Schema::hasTable('guest_notifications')) {
|
||||||
|
return $this->emptyNotificationsResponse($request, $event->id, 'disabled');
|
||||||
|
}
|
||||||
|
|
||||||
$baseQuery = GuestNotification::query()
|
$baseQuery = GuestNotification::query()
|
||||||
->where('event_id', $event->id)
|
->where('event_id', $event->id)
|
||||||
->active()
|
->active()
|
||||||
@@ -1668,6 +1673,30 @@ class EventPublicController extends BaseController
|
|||||||
->header('Vary', 'X-Device-Id, Accept-Language');
|
->header('Vary', 'X-Device-Id, Accept-Language');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function emptyNotificationsResponse(Request $request, int $eventId, string $reason = 'empty'): JsonResponse
|
||||||
|
{
|
||||||
|
$etag = sha1(sprintf('event:%d:guest_notifications:%s', $eventId, $reason));
|
||||||
|
|
||||||
|
$clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags());
|
||||||
|
if (in_array($etag, $clientEtags, true)) {
|
||||||
|
return response()->json([], Response::HTTP_NOT_MODIFIED)
|
||||||
|
->header('ETag', $etag)
|
||||||
|
->header('Cache-Control', 'no-store')
|
||||||
|
->header('Vary', 'X-Device-Id, Accept-Language');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [],
|
||||||
|
'meta' => [
|
||||||
|
'unread_count' => 0,
|
||||||
|
'poll_after_seconds' => 120,
|
||||||
|
'reason' => $reason,
|
||||||
|
],
|
||||||
|
])->header('ETag', $etag)
|
||||||
|
->header('Cache-Control', 'no-store')
|
||||||
|
->header('Vary', 'X-Device-Id, Accept-Language');
|
||||||
|
}
|
||||||
|
|
||||||
public function registerPushSubscription(Request $request, string $token)
|
public function registerPushSubscription(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ use App\Models\GuestNotification;
|
|||||||
use App\Models\GuestNotificationReceipt;
|
use App\Models\GuestNotificationReceipt;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class GuestNotificationService
|
class GuestNotificationService
|
||||||
{
|
{
|
||||||
public function __construct(private readonly Dispatcher $events) {}
|
private bool $notificationsStorageAvailable;
|
||||||
|
|
||||||
|
public function __construct(private readonly Dispatcher $events)
|
||||||
|
{
|
||||||
|
$this->notificationsStorageAvailable = $this->detectStorageAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{payload?: array|null, target_identifier?: string|null, priority?: int|null, expires_at?: \DateTimeInterface|null, status?: GuestNotificationState|null, audience_scope?: GuestNotificationAudience|string|null} $options
|
* @param array{payload?: array|null, target_identifier?: string|null, priority?: int|null, expires_at?: \DateTimeInterface|null, status?: GuestNotificationState|null, audience_scope?: GuestNotificationAudience|string|null} $options
|
||||||
@@ -49,6 +56,12 @@ class GuestNotificationService
|
|||||||
'expires_at' => $options['expires_at'] ?? null,
|
'expires_at' => $options['expires_at'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (! $this->notificationsStorageAvailable) {
|
||||||
|
$this->logStorageWarningOnce();
|
||||||
|
|
||||||
|
return $notification;
|
||||||
|
}
|
||||||
|
|
||||||
$notification->save();
|
$notification->save();
|
||||||
|
|
||||||
$this->events->dispatch(new GuestNotificationCreated($notification));
|
$this->events->dispatch(new GuestNotificationCreated($notification));
|
||||||
@@ -71,6 +84,16 @@ class GuestNotificationService
|
|||||||
$guestIdentifier = $this->sanitizeIdentifier($guestIdentifier) ?? 'anonymous';
|
$guestIdentifier = $this->sanitizeIdentifier($guestIdentifier) ?? 'anonymous';
|
||||||
|
|
||||||
/** @var GuestNotificationReceipt $receipt */
|
/** @var GuestNotificationReceipt $receipt */
|
||||||
|
if (! $this->notificationsStorageAvailable) {
|
||||||
|
$this->logStorageWarningOnce();
|
||||||
|
|
||||||
|
return new GuestNotificationReceipt([
|
||||||
|
'guest_notification_id' => $notification->getKey(),
|
||||||
|
'guest_identifier' => $guestIdentifier,
|
||||||
|
...$this->buildReceiptAttributes($status),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$receipt = GuestNotificationReceipt::query()->updateOrCreate(
|
$receipt = GuestNotificationReceipt::query()->updateOrCreate(
|
||||||
[
|
[
|
||||||
'guest_notification_id' => $notification->getKey(),
|
'guest_notification_id' => $notification->getKey(),
|
||||||
@@ -159,4 +182,25 @@ class GuestNotificationService
|
|||||||
return GuestNotificationAudience::ALL;
|
return GuestNotificationAudience::ALL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function detectStorageAvailability(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return Schema::hasTable('guest_notifications') && Schema::hasTable('guest_notification_receipts');
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logStorageWarningOnce(): void
|
||||||
|
{
|
||||||
|
static $alreadyWarned = false;
|
||||||
|
|
||||||
|
if ($alreadyWarned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alreadyWarned = true;
|
||||||
|
Log::warning('Guest notifications storage tables are missing. Notification persistence skipped.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||||
"eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.",
|
"eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.",
|
||||||
"eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.",
|
"eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.",
|
||||||
"creditsExhausted": "Keine Event-Credits mehr verfügbar. Bitte buche Credits oder upgrade dein Paket.",
|
"creditsExhausted": "Keine Event-Slots mehr verfügbar. Bitte buche zusätzliche Slots oder upgrade dein Paket.",
|
||||||
"photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.",
|
"photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.",
|
||||||
"goToBilling": "Zur Paketverwaltung"
|
"goToBilling": "Zur Paketverwaltung"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
"publishedHint": "{{count}} veröffentlicht",
|
"publishedHint": "{{count}} veröffentlicht",
|
||||||
"newPhotos": "Neue Fotos (7 Tage)",
|
"newPhotos": "Neue Fotos (7 Tage)",
|
||||||
"taskProgress": "Task-Fortschritt",
|
"taskProgress": "Task-Fortschritt",
|
||||||
"credits": "Credits",
|
"credits": "Event-Slots",
|
||||||
"lowCredits": "Auffüllen empfohlen"
|
"lowCredits": "Mehr Slots buchen empfohlen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveNow": {
|
"liveNow": {
|
||||||
@@ -202,8 +202,8 @@
|
|||||||
"publishedHint": "{{count}} veröffentlicht",
|
"publishedHint": "{{count}} veröffentlicht",
|
||||||
"newPhotos": "Neue Fotos (7 Tage)",
|
"newPhotos": "Neue Fotos (7 Tage)",
|
||||||
"taskProgress": "Task-Fortschritt",
|
"taskProgress": "Task-Fortschritt",
|
||||||
"credits": "Credits",
|
"credits": "Event-Slots",
|
||||||
"lowCredits": "Auffüllen empfohlen"
|
"lowCredits": "Mehr Slots buchen empfohlen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
|
|||||||
@@ -773,8 +773,8 @@
|
|||||||
"reset": "Auf Standard setzen"
|
"reset": "Auf Standard setzen"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"creditLast": "Letzte Credit-Warnung: {{date}}",
|
"creditLast": "Letzte Slot-Warnung: {{date}}",
|
||||||
"creditNever": "Noch keine Credit-Warnung versendet."
|
"creditNever": "Noch keine Slot-Warnung versendet."
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"photoThresholds": {
|
"photoThresholds": {
|
||||||
@@ -818,8 +818,8 @@
|
|||||||
"description": "Benachrichtige mich, wenn das Paket abgelaufen ist."
|
"description": "Benachrichtige mich, wenn das Paket abgelaufen ist."
|
||||||
},
|
},
|
||||||
"creditsLow": {
|
"creditsLow": {
|
||||||
"label": "Event-Credits werden knapp",
|
"label": "Event-Slots werden knapp",
|
||||||
"description": "Informiert mich bei niedrigen Credit-Schwellen."
|
"description": "Informiert mich bei niedrigen Slot-Schwellen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"ctaList": {
|
"ctaList": {
|
||||||
"choosePackage": {
|
"choosePackage": {
|
||||||
"label": "Dein Eventpaket auswählen",
|
"label": "Dein Eventpaket auswählen",
|
||||||
"description": "Reserviere Credits oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
|
"description": "Reserviere Event-Slots oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
|
||||||
"button": "Weiter zu Paketen"
|
"button": "Weiter zu Paketen"
|
||||||
},
|
},
|
||||||
"createEvent": {
|
"createEvent": {
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"steps": {
|
"steps": {
|
||||||
"package": {
|
"package": {
|
||||||
"title": "Paket sichern",
|
"title": "Paket sichern",
|
||||||
"hint": "Credits oder ein Abo brauchst du, bevor Gäste live gehen."
|
"hint": "Event-Slots oder ein Abo brauchst du, bevor Gäste live gehen."
|
||||||
},
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Team einladen",
|
"title": "Team einladen",
|
||||||
@@ -74,10 +74,10 @@
|
|||||||
"layout": {
|
"layout": {
|
||||||
"eyebrow": "Schritt 2",
|
"eyebrow": "Schritt 2",
|
||||||
"title": "Wähle dein Eventpaket",
|
"title": "Wähle dein Eventpaket",
|
||||||
"subtitle": "Fotospiel bietet flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
|
"subtitle": "Fotospiel bietet flexible Preismodelle: einmalige Event-Slots oder Abos, die mehrere Events abdecken."
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"title": "Aktiviere die passenden Credits",
|
"title": "Aktiviere die passenden Event-Slots",
|
||||||
"description": "Sichere dir Kapazität für dein nächstes Event. Du kannst jederzeit upgraden – bezahle nur, was du brauchst."
|
"description": "Sichere dir Kapazität für dein nächstes Event. Du kannst jederzeit upgraden – bezahle nur, was du brauchst."
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"subscription": "Abo",
|
"subscription": "Abo",
|
||||||
"creditPack": "Credit-Paket",
|
"creditPack": "Event-Slot-Paket",
|
||||||
"description": "Sofort einsatzbereit für dein nächstes Event.",
|
"description": "Sofort einsatzbereit für dein nächstes Event.",
|
||||||
"descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive – perfekt für lebendige Reportagen.",
|
"descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive – perfekt für lebendige Reportagen.",
|
||||||
"active": "Aktives Paket",
|
"active": "Aktives Paket",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"subscription": "Abo",
|
"subscription": "Abo",
|
||||||
"creditPack": "Credit-Paket",
|
"creditPack": "Event-Slot-Paket",
|
||||||
"photos": "Bis zu {{count}} Fotos",
|
"photos": "Bis zu {{count}} Fotos",
|
||||||
"galleryDays": "Galerie {{count}} Tage",
|
"galleryDays": "Galerie {{count}} Tage",
|
||||||
"guests": "{{count}} Gäste",
|
"guests": "{{count}} Gäste",
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"activate": "Gratis-Paket aktivieren",
|
"activate": "Gratis-Paket aktivieren",
|
||||||
"progress": "Aktivierung läuft …",
|
"progress": "Aktivierung läuft …",
|
||||||
"successTitle": "Gratis-Paket aktiviert",
|
"successTitle": "Gratis-Paket aktiviert",
|
||||||
"successDescription": "Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
|
"successDescription": "Deine Event-Slots wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
|
||||||
"failureTitle": "Aktivierung fehlgeschlagen",
|
"failureTitle": "Aktivierung fehlgeschlagen",
|
||||||
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
|
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
|
||||||
},
|
},
|
||||||
@@ -201,12 +201,12 @@
|
|||||||
"nextSteps": [
|
"nextSteps": [
|
||||||
"Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
|
"Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
|
||||||
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
|
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
|
||||||
"Vor dem Go-Live Credits prüfen und Gäste-Link teilen."
|
"Vor dem Go-Live Event-Slots prüfen und Gäste-Link teilen."
|
||||||
],
|
],
|
||||||
"cta": {
|
"cta": {
|
||||||
"billing": {
|
"billing": {
|
||||||
"label": "Abrechnung starten",
|
"label": "Abrechnung starten",
|
||||||
"description": "Öffnet den Billing-Bereich mit Paddle- und Credit-Optionen.",
|
"description": "Öffnet den Billing-Bereich mit Paddle- und Slot-Optionen.",
|
||||||
"button": "Zu Billing & Zahlung"
|
"button": "Zu Billing & Zahlung"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
"generic": "Something went wrong. Please try again.",
|
"generic": "Something went wrong. Please try again.",
|
||||||
"eventLimit": "Your current package has no remaining event slots.",
|
"eventLimit": "Your current package has no remaining event slots.",
|
||||||
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.",
|
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.",
|
||||||
"creditsExhausted": "You have no event credits remaining. Purchase credits or upgrade your package.",
|
"creditsExhausted": "You have no event slots remaining. Add more slots or upgrade your package.",
|
||||||
"photoLimit": "This event reached its photo upload limit.",
|
"photoLimit": "This event reached its photo upload limit.",
|
||||||
"goToBilling": "Manage subscription"
|
"goToBilling": "Manage subscription"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
"publishedHint": "{{count}} published",
|
"publishedHint": "{{count}} published",
|
||||||
"newPhotos": "New photos (7 days)",
|
"newPhotos": "New photos (7 days)",
|
||||||
"taskProgress": "Task progress",
|
"taskProgress": "Task progress",
|
||||||
"credits": "Credits",
|
"credits": "Event slots",
|
||||||
"lowCredits": "Top up recommended"
|
"lowCredits": "Add slots soon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liveNow": {
|
"liveNow": {
|
||||||
@@ -202,8 +202,8 @@
|
|||||||
"publishedHint": "{{count}} published",
|
"publishedHint": "{{count}} published",
|
||||||
"newPhotos": "New photos (7 days)",
|
"newPhotos": "New photos (7 days)",
|
||||||
"taskProgress": "Task progress",
|
"taskProgress": "Task progress",
|
||||||
"credits": "Credits",
|
"credits": "Event slots",
|
||||||
"lowCredits": "Top up recommended"
|
"lowCredits": "Add slots soon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
|
|||||||
@@ -773,8 +773,8 @@
|
|||||||
"reset": "Reset to defaults"
|
"reset": "Reset to defaults"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"creditLast": "Last credit warning: {{date}}",
|
"creditLast": "Last slot warning: {{date}}",
|
||||||
"creditNever": "No credit warning sent yet."
|
"creditNever": "No slot warning sent yet."
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"photoThresholds": {
|
"photoThresholds": {
|
||||||
@@ -818,8 +818,8 @@
|
|||||||
"description": "Inform me once the package has expired."
|
"description": "Inform me once the package has expired."
|
||||||
},
|
},
|
||||||
"creditsLow": {
|
"creditsLow": {
|
||||||
"label": "Event credits running low",
|
"label": "Event slots running low",
|
||||||
"description": "Warn me when credit thresholds are reached."
|
"description": "Warn me when slot thresholds are reached."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"ctaList": {
|
"ctaList": {
|
||||||
"choosePackage": {
|
"choosePackage": {
|
||||||
"label": "Choose your package",
|
"label": "Choose your package",
|
||||||
"description": "Reserve credits or subscriptions to activate events instantly. Flexible options for any event size.",
|
"description": "Reserve event slots or subscriptions to activate events instantly. Flexible options for any event size.",
|
||||||
"button": "Continue to packages"
|
"button": "Continue to packages"
|
||||||
},
|
},
|
||||||
"createEvent": {
|
"createEvent": {
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"steps": {
|
"steps": {
|
||||||
"package": {
|
"package": {
|
||||||
"title": "Secure your package",
|
"title": "Secure your package",
|
||||||
"hint": "Credits or a subscription are required before guests go live."
|
"hint": "Event slots or a subscription are required before guests go live."
|
||||||
},
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Invite your co-hosts",
|
"title": "Invite your co-hosts",
|
||||||
@@ -74,10 +74,10 @@
|
|||||||
"layout": {
|
"layout": {
|
||||||
"eyebrow": "Step 2",
|
"eyebrow": "Step 2",
|
||||||
"title": "Choose your package",
|
"title": "Choose your package",
|
||||||
"subtitle": "Fotospiel supports flexible pricing: single-use credits or subscriptions covering multiple events."
|
"subtitle": "Fotospiel supports flexible pricing: single-use event slots or subscriptions covering multiple events."
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"title": "Activate the right credits",
|
"title": "Activate the right plan",
|
||||||
"description": "Secure capacity for your next event. Upgrade at any time – only pay for what you need."
|
"description": "Secure capacity for your next event. Upgrade at any time – only pay for what you need."
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"creditPack": "Credit pack",
|
"creditPack": "Event slot pack",
|
||||||
"description": "Ready for your next event right away.",
|
"description": "Ready for your next event right away.",
|
||||||
"descriptionWithPhotos": "Up to {{count}} photos included – perfect for vibrant storytelling.",
|
"descriptionWithPhotos": "Up to {{count}} photos included – perfect for vibrant storytelling.",
|
||||||
"active": "Active package",
|
"active": "Active package",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"creditPack": "Credit pack",
|
"creditPack": "Event slot pack",
|
||||||
"photos": "Up to {{count}} photos",
|
"photos": "Up to {{count}} photos",
|
||||||
"galleryDays": "{{count}} gallery days",
|
"galleryDays": "{{count}} gallery days",
|
||||||
"guests": "{{count}} guests",
|
"guests": "{{count}} guests",
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"activate": "Activate free package",
|
"activate": "Activate free package",
|
||||||
"progress": "Activating …",
|
"progress": "Activating …",
|
||||||
"successTitle": "Free package activated",
|
"successTitle": "Free package activated",
|
||||||
"successDescription": "Credits added. Continue with the setup.",
|
"successDescription": "Event slots added. Continue with the setup.",
|
||||||
"failureTitle": "Activation failed",
|
"failureTitle": "Activation failed",
|
||||||
"errorMessage": "The free package could not be activated."
|
"errorMessage": "The free package could not be activated."
|
||||||
},
|
},
|
||||||
@@ -201,12 +201,12 @@
|
|||||||
"nextSteps": [
|
"nextSteps": [
|
||||||
"Optional: finish billing via Paddle inside the billing area.",
|
"Optional: finish billing via Paddle inside the billing area.",
|
||||||
"Complete the event setup and configure tasks, team, and gallery.",
|
"Complete the event setup and configure tasks, team, and gallery.",
|
||||||
"Check credits before go-live and share your guest link."
|
"Check your event slots before go-live and share your guest link."
|
||||||
],
|
],
|
||||||
"cta": {
|
"cta": {
|
||||||
"billing": {
|
"billing": {
|
||||||
"label": "Start billing",
|
"label": "Start billing",
|
||||||
"description": "Opens the billing area with Paddle and credit options.",
|
"description": "Opens the billing area with Paddle plan options.",
|
||||||
"button": "Go to billing"
|
"button": "Go to billing"
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AlertTriangle, LogOut, Palette, UserCog } from 'lucide-react';
|
import { LogOut, UserCog } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
|
||||||
@@ -250,12 +249,12 @@ function NotificationPreferencesForm({
|
|||||||
if (meta.credit_warning_sent_at) {
|
if (meta.credit_warning_sent_at) {
|
||||||
const date = formatDateTime(meta.credit_warning_sent_at, locale);
|
const date = formatDateTime(meta.credit_warning_sent_at, locale);
|
||||||
|
|
||||||
return translate('settings.notifications.meta.creditLast', 'Letzte Credit-Warnung: {{date}}', {
|
return translate('settings.notifications.meta.creditLast', 'Letzte Slot-Warnung: {{date}}', {
|
||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return translate('settings.notifications.meta.creditNever', 'Noch keine Credit-Warnung versendet.');
|
return translate('settings.notifications.meta.creditNever', 'Noch keine Slot-Warnung versendet.');
|
||||||
}, [meta, translate, locale]);
|
}, [meta, translate, locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -350,8 +349,8 @@ function buildPreferenceMeta(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'credits_low',
|
key: 'credits_low',
|
||||||
label: translate('settings.notifications.items.creditsLow.label', 'Event-Credits werden knapp'),
|
label: translate('settings.notifications.items.creditsLow.label', 'Event-Slots werden knapp'),
|
||||||
description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Credit-Schwellen.'),
|
description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Slot-Schwellen.'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ function TabLink({
|
|||||||
children,
|
children,
|
||||||
isActive,
|
isActive,
|
||||||
accentColor,
|
accentColor,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
to: string;
|
to: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const activeStyle = isActive
|
const activeStyle = isActive
|
||||||
? {
|
? {
|
||||||
@@ -28,7 +30,7 @@ function TabLink({
|
|||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
className={`
|
className={`
|
||||||
flex h-14 flex-col items-center justify-center gap-1 rounded-lg border border-transparent p-2 text-xs font-medium transition-all duration-200 ease-out
|
flex ${compact ? 'h-10 text-[10px]' : 'h-14 text-xs'} flex-col items-center justify-center gap-1 rounded-lg border border-transparent p-2 font-medium transition-all duration-200 ease-out
|
||||||
touch-manipulation backdrop-blur-md
|
touch-manipulation backdrop-blur-md
|
||||||
${isActive ? 'scale-[1.04]' : 'text-white/70 hover:text-white'}
|
${isActive ? 'scale-[1.04]' : 'text-white/70 hover:text-white'}
|
||||||
`}
|
`}
|
||||||
@@ -67,17 +69,23 @@ export default function BottomNav() {
|
|||||||
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||||
const isUploadActive = currentPath.startsWith(`${base}/upload`);
|
const isUploadActive = currentPath.startsWith(`${base}/upload`);
|
||||||
|
|
||||||
|
const compact = isUploadActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 pb-3 pt-2 shadow-xl backdrop-blur-2xl dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35">
|
<div
|
||||||
|
className={`fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
|
||||||
|
compact ? 'pb-1 pt-1 translate-y-3' : 'pb-3 pt-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="mx-auto flex max-w-lg items-center gap-3">
|
<div className="mx-auto flex max-w-lg items-center gap-3">
|
||||||
<div className="flex flex-1 justify-evenly gap-2">
|
<div className="flex flex-1 justify-evenly gap-2">
|
||||||
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
|
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor} compact={compact}>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Home className="h-5 w-5" aria-hidden />
|
<Home className="h-5 w-5" aria-hidden />
|
||||||
<span>{labels.home}</span>
|
<span>{labels.home}</span>
|
||||||
</div>
|
</div>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
|
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor} compact={compact}>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<CheckSquare className="h-5 w-5" aria-hidden />
|
<CheckSquare className="h-5 w-5" aria-hidden />
|
||||||
<span>{labels.tasks}</span>
|
<span>{labels.tasks}</span>
|
||||||
@@ -88,7 +96,7 @@ export default function BottomNav() {
|
|||||||
<Link
|
<Link
|
||||||
to={`${base}/upload`}
|
to={`${base}/upload`}
|
||||||
aria-label={labels.upload}
|
aria-label={labels.upload}
|
||||||
className={`relative flex h-16 w-16 items-center justify-center rounded-full text-white shadow-2xl transition ${
|
className={`relative flex ${compact ? 'h-12 w-12' : 'h-16 w-16'} items-center justify-center rounded-full text-white shadow-2xl transition ${
|
||||||
isUploadActive ? 'scale-105' : 'hover:scale-105'
|
isUploadActive ? 'scale-105' : 'hover:scale-105'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -100,13 +108,23 @@ export default function BottomNav() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-1 justify-evenly gap-2">
|
<div className="flex flex-1 justify-evenly gap-2">
|
||||||
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive} accentColor={branding.primaryColor}>
|
<TabLink
|
||||||
|
to={`${base}/achievements`}
|
||||||
|
isActive={isAchievementsActive}
|
||||||
|
accentColor={branding.primaryColor}
|
||||||
|
compact={compact}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Trophy className="h-5 w-5" aria-hidden />
|
<Trophy className="h-5 w-5" aria-hidden />
|
||||||
<span>{labels.achievements}</span>
|
<span>{labels.achievements}</span>
|
||||||
</div>
|
</div>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
<TabLink to={`${base}/gallery`} isActive={isGalleryActive} accentColor={branding.primaryColor}>
|
<TabLink
|
||||||
|
to={`${base}/gallery`}
|
||||||
|
isActive={isGalleryActive}
|
||||||
|
accentColor={branding.primaryColor}
|
||||||
|
compact={compact}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<GalleryHorizontal className="h-5 w-5" aria-hidden />
|
<GalleryHorizontal className="h-5 w-5" aria-hidden />
|
||||||
<span>{labels.gallery}</span>
|
<span>{labels.gallery}</span>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
steps: {
|
steps: {
|
||||||
first: 'Aufgabe auswählen oder starten',
|
first: 'Aufgabe auswählen oder starten',
|
||||||
second: 'Emotion festhalten und Foto schießen',
|
second: 'Emotion festhalten und Foto schießen',
|
||||||
third: 'Bild hochladen und Credits sammeln',
|
third: 'Bild hochladen und den Moment teilen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
latestUpload: {
|
latestUpload: {
|
||||||
@@ -809,7 +809,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
steps: {
|
steps: {
|
||||||
first: 'Pick or start a task',
|
first: 'Pick or start a task',
|
||||||
second: 'Capture the emotion and take the photo',
|
second: 'Capture the emotion and take the photo',
|
||||||
third: 'Upload the picture and earn credits',
|
third: 'Upload the picture and share the moment with everyone',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
latestUpload: {
|
latestUpload: {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import Header from '../components/Header';
|
|
||||||
import BottomNav from '../components/BottomNav';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
@@ -19,6 +17,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Camera,
|
Camera,
|
||||||
|
ChevronDown,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Info,
|
Info,
|
||||||
@@ -109,7 +108,7 @@ export default function UploadPage() {
|
|||||||
const eventKey = token ?? '';
|
const eventKey = token ?? '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { markCompleted, completedCount } = useGuestTaskProgress(token);
|
const { markCompleted } = useGuestTaskProgress(token);
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const stats = useEventStats();
|
const stats = useEventStats();
|
||||||
|
|
||||||
@@ -137,7 +136,8 @@ const [uploadProgress, setUploadProgress] = useState(0);
|
|||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||||||
|
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
|
||||||
|
|
||||||
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
||||||
const [canUpload, setCanUpload] = useState(true);
|
const [canUpload, setCanUpload] = useState(true);
|
||||||
@@ -616,10 +616,13 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}, [canUpload, t]);
|
}, [canUpload, t]);
|
||||||
|
|
||||||
const handleOpenInspiration = useCallback(() => {
|
const emotionLabel = useMemo(() => {
|
||||||
if (!eventKey) return;
|
if (task?.emotion?.name) return task.emotion.name;
|
||||||
navigate(`/e/${encodeURIComponent(eventKey)}/gallery`);
|
if (emotionSlug) {
|
||||||
}, [eventKey, navigate]);
|
return emotionSlug.replace('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
return t('upload.hud.moodFallback');
|
||||||
|
}, [emotionSlug, t, task?.emotion?.name]);
|
||||||
|
|
||||||
const difficultyBadgeClass = useMemo(() => {
|
const difficultyBadgeClass = useMemo(() => {
|
||||||
if (!task) return 'text-white';
|
if (!task) return 'text-white';
|
||||||
@@ -641,6 +644,27 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
[stats.latestPhotoAt, t],
|
[stats.latestPhotoAt, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const socialChips = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'online',
|
||||||
|
label: t('upload.hud.cards.online'),
|
||||||
|
value: stats.onlineGuests > 0 ? `${stats.onlineGuests}` : '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotion',
|
||||||
|
label: t('upload.taskInfo.emotion').replace('{value}', emotionLabel),
|
||||||
|
value: t('upload.hud.moodLabel').replace('{mood}', emotionLabel),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last-upload',
|
||||||
|
label: t('upload.hud.cards.lastUpload'),
|
||||||
|
value: relativeLastUpload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[emotionLabel, relativeLastUpload, stats.onlineGuests, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
resetCountdownTimer();
|
resetCountdownTimer();
|
||||||
if (uploadProgressTimerRef.current) {
|
if (uploadProgressTimerRef.current) {
|
||||||
@@ -648,7 +672,64 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
}
|
}
|
||||||
}, [resetCountdownTimer]);
|
}, [resetCountdownTimer]);
|
||||||
|
|
||||||
const heroEmotion = task?.emotion?.name ?? t('upload.hud.moodFallback');
|
useEffect(() => {
|
||||||
|
setTaskDetailsExpanded(false);
|
||||||
|
}, [task?.id]);
|
||||||
|
|
||||||
|
const handlePrimaryAction = useCallback(() => {
|
||||||
|
if (!isCameraActive) {
|
||||||
|
startCamera();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
beginCapture();
|
||||||
|
}, [beginCapture, isCameraActive, startCamera]);
|
||||||
|
|
||||||
|
const taskFloatingCard = showTaskOverlay && task ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTaskDetailsExpanded((prev) => !prev)}
|
||||||
|
className="absolute left-6 right-6 top-0 z-30 -translate-y-1/2 rounded-3xl border border-white/40 bg-black/70 p-4 text-left text-white shadow-2xl backdrop-blur transition hover:bg-black/80 focus:outline-none focus:ring-2 focus:ring-white/60"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-2 rounded-full text-[11px]">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
|
||||||
|
</Badge>
|
||||||
|
<span className={cn('text-xs font-semibold uppercase tracking-wide', difficultyBadgeClass)}>
|
||||||
|
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center text-xs uppercase tracking-wide text-white/70">
|
||||||
|
{emotionLabel}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn('ml-1 h-3.5 w-3.5 transition', taskDetailsExpanded ? 'rotate-180' : 'rotate-0')}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-semibold leading-snug">{task.title}</p>
|
||||||
|
{taskDetailsExpanded ? (
|
||||||
|
<div className="mt-2 space-y-2 text-xs text-white/80">
|
||||||
|
<p>{task.description}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||||
|
{task.instructions && (
|
||||||
|
<span>
|
||||||
|
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{preferences.countdownEnabled && (
|
||||||
|
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
||||||
|
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{emotionLabel && (
|
||||||
|
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
||||||
|
{t('upload.taskInfo.emotion').replace('{value}', emotionLabel)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const limitStatusSection = limitCards.length > 0 ? (
|
const limitStatusSection = limitCards.length > 0 ? (
|
||||||
<section className="space-y-4 rounded-[28px] border border-white/20 bg-white/80 p-5 shadow-lg backdrop-blur dark:border-white/10 dark:bg-slate-900/60">
|
<section className="space-y-4 rounded-[28px] border border-white/20 bg-white/80 p-5 shadow-lg backdrop-blur dark:border-white/10 dark:bg-slate-900/60">
|
||||||
@@ -735,28 +816,11 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
|
|
||||||
const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => (
|
const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => (
|
||||||
<>
|
<>
|
||||||
<div className="pb-16">
|
<div className={wrapperClassName}>{content}</div>
|
||||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
|
||||||
<main className="px-4">
|
|
||||||
<div className={wrapperClassName}>{content}</div>
|
|
||||||
</main>
|
|
||||||
<BottomNav />
|
|
||||||
</div>
|
|
||||||
{errorDialogNode}
|
{errorDialogNode}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!supportsCamera && !task) {
|
|
||||||
return renderWithDialog(
|
|
||||||
<>
|
|
||||||
{limitStatusSection}
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadingTask) {
|
if (loadingTask) {
|
||||||
return renderWithDialog(
|
return renderWithDialog(
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||||
@@ -804,35 +868,59 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
|
|
||||||
const renderPermissionNotice = () => {
|
const renderPermissionNotice = () => {
|
||||||
if (permissionState === 'granted') return null;
|
if (permissionState === 'granted') return null;
|
||||||
if (permissionState === 'unsupported') {
|
|
||||||
return (
|
const titles: Record<PermissionState, string> = {
|
||||||
<Alert className="rounded-[24px] border border-amber-200 bg-amber-50/70 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
|
idle: t('upload.cameraDenied.title'),
|
||||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
prompt: t('upload.cameraDenied.title'),
|
||||||
</Alert>
|
granted: '',
|
||||||
);
|
denied: t('upload.cameraDenied.title'),
|
||||||
}
|
error: t('upload.cameraError.title'),
|
||||||
if (permissionState === 'denied' || permissionState === 'error') {
|
unsupported: t('upload.cameraUnsupported.title'),
|
||||||
return (
|
};
|
||||||
<Alert variant="destructive" className="rounded-[24px] border border-rose-200 bg-rose-50/80 text-rose-900 dark:border-rose-500/40 dark:bg-rose-500/15 dark:text-rose-50">
|
|
||||||
<AlertDescription className="space-y-3">
|
const fallbackMessages: Record<PermissionState, string> = {
|
||||||
<div>{permissionMessage}</div>
|
idle: t('upload.cameraDenied.prompt'),
|
||||||
<Button size="sm" variant="outline" onClick={startCamera}>
|
prompt: t('upload.cameraDenied.prompt'),
|
||||||
{t('upload.buttons.tryAgain')}
|
granted: '',
|
||||||
</Button>
|
denied: t('upload.cameraDenied.explanation'),
|
||||||
</AlertDescription>
|
error: t('upload.cameraError.explanation'),
|
||||||
</Alert>
|
unsupported: t('upload.cameraUnsupported.message'),
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
const title = titles[permissionState];
|
||||||
|
const description = permissionMessage ?? fallbackMessages[permissionState];
|
||||||
|
const canRetryCamera = permissionState !== 'unsupported';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert className="rounded-[24px] border border-slate-200 bg-white/80 text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-white">
|
<div className="rounded-[32px] border border-white/15 bg-white/85 p-5 text-slate-900 shadow-lg dark:border-white/10 dark:bg-white/5 dark:text-white">
|
||||||
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
|
<div className="flex items-center gap-3">
|
||||||
</Alert>
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900/10 dark:bg-white/10">
|
||||||
|
<Camera className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{title}</p>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-white/70">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
{canRetryCamera && (
|
||||||
|
<Button onClick={startCamera} size="sm">
|
||||||
|
{t('upload.buttons.startCamera')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
{t('upload.galleryButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return renderWithDialog(
|
return renderWithDialog(
|
||||||
<>
|
<>
|
||||||
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
|
<div className="relative pt-8">
|
||||||
|
{taskFloatingCard}
|
||||||
|
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
|
||||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -857,57 +945,13 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCameraActive && (
|
{!isCameraActive && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70 text-center text-sm">
|
<div className="absolute left-4 top-4 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">
|
||||||
<Camera className="mb-3 h-8 w-8 text-pink-400" />
|
<Camera className="h-4 w-4 text-pink-400" />
|
||||||
<p className="max-w-xs text-white/90">
|
<span>
|
||||||
{t('upload.cameraInactive').replace(
|
{permissionState === 'unsupported'
|
||||||
'{hint}',
|
? t('upload.cameraUnsupported.title')
|
||||||
(permissionMessage ?? t('upload.cameraInactiveHint').replace('{label}', t('upload.buttons.startCamera')))
|
: t('upload.cameraDenied.title')}
|
||||||
)}
|
</span>
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
<Button size="sm" onClick={startCamera}>
|
|
||||||
{t('upload.buttons.startCamera')}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()}>
|
|
||||||
{t('upload.galleryButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showTaskOverlay && task && (
|
|
||||||
<div className="absolute left-3 right-3 top-3 z-30 flex flex-col gap-2 rounded-xl border border-white/15 bg-black/40 p-3 backdrop-blur-sm">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<Badge variant="secondary" className="flex items-center gap-2 text-xs">
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
{t('upload.taskInfo.badge').replace('{id}', `${task.id}`)}
|
|
||||||
</Badge>
|
|
||||||
<span className={cn('text-xs font-medium uppercase tracking-wide', difficultyBadgeClass)}>
|
|
||||||
{t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold leading-tight">{task.title}</h1>
|
|
||||||
<p className="mt-1 text-xs leading-relaxed text-white/80">{task.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/70">
|
|
||||||
{task.instructions && (
|
|
||||||
<span>
|
|
||||||
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{emotionSlug && (
|
|
||||||
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
|
||||||
{t('upload.taskInfo.emotion').replace('{value}', `${task.emotion?.name || emotionSlug}`)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{preferences.countdownEnabled && (
|
|
||||||
<span className="rounded-full border border-white/20 px-2 py-0.5">
|
|
||||||
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -940,7 +984,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-30 flex flex-col gap-3 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
|
<div className="relative z-30 flex flex-col gap-4 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
|
||||||
{uploadWarning && (
|
{uploadWarning && (
|
||||||
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
|
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
|
||||||
<AlertDescription className="text-xs">
|
<AlertDescription className="text-xs">
|
||||||
@@ -957,72 +1001,67 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-center gap-2 text-xs font-medium uppercase tracking-wide text-white/80">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="icon"
|
variant={preferences.gridEnabled ? 'default' : 'secondary'}
|
||||||
variant={preferences.gridEnabled ? 'default' : 'secondary'}
|
className={cn(
|
||||||
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
|
||||||
onClick={handleToggleGrid}
|
preferences.gridEnabled && 'bg-white text-black'
|
||||||
>
|
|
||||||
<Grid3X3 className="h-5 w-5" />
|
|
||||||
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant={preferences.countdownEnabled ? 'default' : 'secondary'}
|
|
||||||
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
|
||||||
onClick={handleToggleCountdown}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span>
|
|
||||||
<span className="sr-only">{t('upload.controls.toggleCountdown')}</span>
|
|
||||||
</Button>
|
|
||||||
{preferences.facingMode === 'user' && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
|
|
||||||
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
|
||||||
onClick={handleToggleMirror}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold">?</span>
|
|
||||||
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
onClick={handleToggleGrid}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t('upload.controls.toggleGrid')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={preferences.countdownEnabled ? 'default' : 'secondary'}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
|
||||||
|
preferences.countdownEnabled && 'bg-white text-black'
|
||||||
|
)}
|
||||||
|
onClick={handleToggleCountdown}
|
||||||
|
>
|
||||||
|
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
|
||||||
|
</Button>
|
||||||
|
{preferences.facingMode === 'user' && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
|
||||||
variant={preferences.flashPreferred ? 'default' : 'secondary'}
|
|
||||||
className="h-10 w-10 rounded-full bg-white/15 text-white"
|
|
||||||
onClick={handleToggleFlashPreference}
|
|
||||||
disabled={preferences.facingMode !== 'environment'}
|
|
||||||
>
|
|
||||||
{preferences.flashPreferred ? <Zap className="h-5 w-5 text-yellow-300" /> : <ZapOff className="h-5 w-5" />}
|
|
||||||
<span className="sr-only">{t('upload.controls.toggleFlash')}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-full border-white/30 bg-white/10 text-white"
|
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
|
||||||
onClick={handleSwitchCamera}
|
className={cn(
|
||||||
|
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
|
||||||
|
preferences.mirrorFrontPreview && 'bg-white text-black'
|
||||||
|
)}
|
||||||
|
onClick={handleToggleMirror}
|
||||||
>
|
>
|
||||||
<RotateCcw className="mr-1 h-4 w-4" />
|
{t('upload.controls.toggleMirror')}
|
||||||
{t('upload.switchCamera')}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
)}
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-full border-white/30 bg-white/10 text-white"
|
variant={preferences.flashPreferred ? 'default' : 'secondary'}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
className={cn(
|
||||||
>
|
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
|
||||||
<ImagePlus className="mr-1 h-4 w-4" />
|
preferences.flashPreferred && 'bg-white text-black'
|
||||||
{t('upload.galleryButton')}
|
)}
|
||||||
</Button>
|
onClick={handleToggleFlashPreference}
|
||||||
</div>
|
disabled={preferences.facingMode !== 'environment'}
|
||||||
|
>
|
||||||
|
{preferences.flashPreferred ? <Zap className="mr-1 h-3.5 w-3.5 text-yellow-300" /> : <ZapOff className="mr-1 h-3.5 w-3.5" />}
|
||||||
|
{t('upload.controls.toggleFlash')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-6 w-6" />
|
||||||
|
<span className="sr-only">{t('upload.galleryButton')}</span>
|
||||||
|
</Button>
|
||||||
{mode === 'review' && reviewPhoto ? (
|
{mode === 'review' && reviewPhoto ? (
|
||||||
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
|
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
|
||||||
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
|
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
|
||||||
@@ -1035,17 +1074,42 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-16 w-16 rounded-full border-4 border-white/40 bg-white/90 text-black shadow-xl"
|
className="flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 bg-white text-black shadow-2xl"
|
||||||
onClick={beginCapture}
|
onClick={handlePrimaryAction}
|
||||||
disabled={!isCameraActive || mode === 'countdown'}
|
disabled={mode === 'countdown' || mode === 'uploading'}
|
||||||
>
|
>
|
||||||
<Camera className="h-7 w-7" />
|
<Camera className="h-8 w-8" />
|
||||||
<span className="sr-only">{t('upload.captureButton')}</span>
|
<span className="sr-only">
|
||||||
|
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex h-14 w-14 items-center justify-center rounded-full border border-white/30 bg-white/10 text-white"
|
||||||
|
onClick={handleSwitchCamera}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-6 w-6" />
|
||||||
|
<span className="sr-only">{t('upload.switchCamera')}</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{socialChips.length > 0 && (
|
||||||
|
<div className="mt-4 flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{socialChips.map((chip) => (
|
||||||
|
<div
|
||||||
|
key={chip.id}
|
||||||
|
className="shrink-0 rounded-full border border-white/15 bg-white/80 px-4 py-2 text-xs font-semibold text-slate-800 shadow dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||||
|
>
|
||||||
|
<span className="block text-[10px] uppercase tracking-wide opacity-70">{chip.label}</span>
|
||||||
|
<span className="text-sm">{chip.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||||
{limitStatusSection}
|
{limitStatusSection}
|
||||||
|
|||||||
Reference in New Issue
Block a user