diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index e50bc15..7e52fc0 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -33,6 +33,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; @@ -1618,6 +1619,10 @@ class EventPublicController extends BaseController $guestIdentifier = $this->resolveNotificationIdentifier($request); $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() ->where('event_id', $event->id) ->active() @@ -1668,6 +1673,30 @@ class EventPublicController extends BaseController ->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) { $result = $this->resolvePublishedEvent($request, $token, ['id']); diff --git a/app/Services/GuestNotificationService.php b/app/Services/GuestNotificationService.php index ac44cd3..ed27b9b 100644 --- a/app/Services/GuestNotificationService.php +++ b/app/Services/GuestNotificationService.php @@ -12,11 +12,18 @@ use App\Models\GuestNotification; use App\Models\GuestNotificationReceipt; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; use Throwable; 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 @@ -49,6 +56,12 @@ class GuestNotificationService 'expires_at' => $options['expires_at'] ?? null, ]); + if (! $this->notificationsStorageAvailable) { + $this->logStorageWarningOnce(); + + return $notification; + } + $notification->save(); $this->events->dispatch(new GuestNotificationCreated($notification)); @@ -71,6 +84,16 @@ class GuestNotificationService $guestIdentifier = $this->sanitizeIdentifier($guestIdentifier) ?? 'anonymous'; /** @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( [ 'guest_notification_id' => $notification->getKey(), @@ -159,4 +182,25 @@ class GuestNotificationService 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.'); + } } diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 9f9ac30..e29fb00 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -57,7 +57,7 @@ "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.", "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.", "goToBilling": "Zur Paketverwaltung" }, diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index 1ce9ebc..2083221 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -32,8 +32,8 @@ "publishedHint": "{{count}} veröffentlicht", "newPhotos": "Neue Fotos (7 Tage)", "taskProgress": "Task-Fortschritt", - "credits": "Credits", - "lowCredits": "Auffüllen empfohlen" + "credits": "Event-Slots", + "lowCredits": "Mehr Slots buchen empfohlen" } }, "liveNow": { @@ -202,8 +202,8 @@ "publishedHint": "{{count}} veröffentlicht", "newPhotos": "Neue Fotos (7 Tage)", "taskProgress": "Task-Fortschritt", - "credits": "Credits", - "lowCredits": "Auffüllen empfohlen" + "credits": "Event-Slots", + "lowCredits": "Mehr Slots buchen empfohlen" } }, "quickActions": { diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 297f9e2..78f01bc 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -773,8 +773,8 @@ "reset": "Auf Standard setzen" }, "meta": { - "creditLast": "Letzte Credit-Warnung: {{date}}", - "creditNever": "Noch keine Credit-Warnung versendet." + "creditLast": "Letzte Slot-Warnung: {{date}}", + "creditNever": "Noch keine Slot-Warnung versendet." }, "items": { "photoThresholds": { @@ -818,8 +818,8 @@ "description": "Benachrichtige mich, wenn das Paket abgelaufen ist." }, "creditsLow": { - "label": "Event-Credits werden knapp", - "description": "Informiert mich bei niedrigen Credit-Schwellen." + "label": "Event-Slots werden knapp", + "description": "Informiert mich bei niedrigen Slot-Schwellen." } } } diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index be36497..b2611fd 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -38,7 +38,7 @@ "ctaList": { "choosePackage": { "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" }, "createEvent": { @@ -58,7 +58,7 @@ "steps": { "package": { "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": { "title": "Team einladen", @@ -74,10 +74,10 @@ "layout": { "eyebrow": "Schritt 2", "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": { - "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." }, "state": { @@ -89,7 +89,7 @@ }, "card": { "subscription": "Abo", - "creditPack": "Credit-Paket", + "creditPack": "Event-Slot-Paket", "description": "Sofort einsatzbereit für dein nächstes Event.", "descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive – perfekt für lebendige Reportagen.", "active": "Aktives Paket", @@ -147,7 +147,7 @@ }, "details": { "subscription": "Abo", - "creditPack": "Credit-Paket", + "creditPack": "Event-Slot-Paket", "photos": "Bis zu {{count}} Fotos", "galleryDays": "Galerie {{count}} Tage", "guests": "{{count}} Gäste", @@ -184,7 +184,7 @@ "activate": "Gratis-Paket aktivieren", "progress": "Aktivierung läuft …", "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", "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." }, @@ -201,12 +201,12 @@ "nextSteps": [ "Optional: Abrechnung über Paddle im Billing-Bereich abschließen.", "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": { "billing": { "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" }, "setup": { diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 34abed0..ab4d02b 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -57,7 +57,7 @@ "generic": "Something went wrong. Please try again.", "eventLimit": "Your current package has no remaining event slots.", "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.", "goToBilling": "Manage subscription" }, diff --git a/resources/js/admin/i18n/locales/en/dashboard.json b/resources/js/admin/i18n/locales/en/dashboard.json index 22eb156..546f937 100644 --- a/resources/js/admin/i18n/locales/en/dashboard.json +++ b/resources/js/admin/i18n/locales/en/dashboard.json @@ -32,8 +32,8 @@ "publishedHint": "{{count}} published", "newPhotos": "New photos (7 days)", "taskProgress": "Task progress", - "credits": "Credits", - "lowCredits": "Top up recommended" + "credits": "Event slots", + "lowCredits": "Add slots soon" } }, "liveNow": { @@ -202,8 +202,8 @@ "publishedHint": "{{count}} published", "newPhotos": "New photos (7 days)", "taskProgress": "Task progress", - "credits": "Credits", - "lowCredits": "Top up recommended" + "credits": "Event slots", + "lowCredits": "Add slots soon" } }, "quickActions": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index fb40b3f..32f2f02 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -773,8 +773,8 @@ "reset": "Reset to defaults" }, "meta": { - "creditLast": "Last credit warning: {{date}}", - "creditNever": "No credit warning sent yet." + "creditLast": "Last slot warning: {{date}}", + "creditNever": "No slot warning sent yet." }, "items": { "photoThresholds": { @@ -818,8 +818,8 @@ "description": "Inform me once the package has expired." }, "creditsLow": { - "label": "Event credits running low", - "description": "Warn me when credit thresholds are reached." + "label": "Event slots running low", + "description": "Warn me when slot thresholds are reached." } } } diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index 07041ea..4b71405 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -38,7 +38,7 @@ "ctaList": { "choosePackage": { "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" }, "createEvent": { @@ -58,7 +58,7 @@ "steps": { "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": { "title": "Invite your co-hosts", @@ -74,10 +74,10 @@ "layout": { "eyebrow": "Step 2", "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": { - "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." }, "state": { @@ -89,7 +89,7 @@ }, "card": { "subscription": "Subscription", - "creditPack": "Credit pack", + "creditPack": "Event slot pack", "description": "Ready for your next event right away.", "descriptionWithPhotos": "Up to {{count}} photos included – perfect for vibrant storytelling.", "active": "Active package", @@ -147,7 +147,7 @@ }, "details": { "subscription": "Subscription", - "creditPack": "Credit pack", + "creditPack": "Event slot pack", "photos": "Up to {{count}} photos", "galleryDays": "{{count}} gallery days", "guests": "{{count}} guests", @@ -184,7 +184,7 @@ "activate": "Activate free package", "progress": "Activating …", "successTitle": "Free package activated", - "successDescription": "Credits added. Continue with the setup.", + "successDescription": "Event slots added. Continue with the setup.", "failureTitle": "Activation failed", "errorMessage": "The free package could not be activated." }, @@ -201,12 +201,12 @@ "nextSteps": [ "Optional: finish billing via Paddle inside the billing area.", "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": { "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" }, "setup": { diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index cd8efc4..5809efa 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -1,10 +1,9 @@ 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 AppearanceToggleDropdown from '@/components/appearance-dropdown'; import { Button } from '@/components/ui/button'; -import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -250,12 +249,12 @@ function NotificationPreferencesForm({ if (meta.credit_warning_sent_at) { 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, }); } - 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]); return ( @@ -350,8 +349,8 @@ function buildPreferenceMeta( }, { key: 'credits_low', - label: translate('settings.notifications.items.creditsLow.label', 'Event-Credits werden knapp'), - description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Credit-Schwellen.'), + label: translate('settings.notifications.items.creditsLow.label', 'Event-Slots werden knapp'), + description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Slot-Schwellen.'), }, ]; diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index d229d39..41a1f6f 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -10,11 +10,13 @@ function TabLink({ children, isActive, accentColor, + compact = false, }: { to: string; children: React.ReactNode; isActive: boolean; accentColor: string; + compact?: boolean; }) { const activeStyle = isActive ? { @@ -28,7 +30,7 @@ function TabLink({ +
- +
{labels.home}
- +
{labels.tasks} @@ -88,7 +96,7 @@ export default function BottomNav() {
- +
{labels.achievements}
- +
{labels.gallery} diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 657c644..665dcd6 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -172,7 +172,7 @@ export const messages: Record = { steps: { first: 'Aufgabe auswählen oder starten', second: 'Emotion festhalten und Foto schießen', - third: 'Bild hochladen und Credits sammeln', + third: 'Bild hochladen und den Moment teilen', }, }, latestUpload: { @@ -809,7 +809,7 @@ export const messages: Record = { steps: { first: 'Pick or start a task', 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: { diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 341a919..a348720 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; 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 { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -19,6 +17,7 @@ import { cn } from '@/lib/utils'; import { AlertTriangle, Camera, + ChevronDown, Grid3X3, ImagePlus, Info, @@ -109,7 +108,7 @@ export default function UploadPage() { const eventKey = token ?? ''; const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { markCompleted, completedCount } = useGuestTaskProgress(token); + const { markCompleted } = useGuestTaskProgress(token); const { t, locale } = useTranslation(); const stats = useEventStats(); @@ -137,7 +136,8 @@ const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [uploadWarning, setUploadWarning] = useState(null); -const [errorDialog, setErrorDialog] = useState(null); + const [errorDialog, setErrorDialog] = useState(null); + const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false); const [eventPackage, setEventPackage] = useState(null); const [canUpload, setCanUpload] = useState(true); @@ -616,10 +616,13 @@ const [canUpload, setCanUpload] = useState(true); reader.readAsDataURL(file); }, [canUpload, t]); - const handleOpenInspiration = useCallback(() => { - if (!eventKey) return; - navigate(`/e/${encodeURIComponent(eventKey)}/gallery`); - }, [eventKey, navigate]); + const emotionLabel = useMemo(() => { + if (task?.emotion?.name) return task.emotion.name; + if (emotionSlug) { + return emotionSlug.replace('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); + } + return t('upload.hud.moodFallback'); + }, [emotionSlug, t, task?.emotion?.name]); const difficultyBadgeClass = useMemo(() => { if (!task) return 'text-white'; @@ -641,6 +644,27 @@ const [canUpload, setCanUpload] = useState(true); [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(() => () => { resetCountdownTimer(); if (uploadProgressTimerRef.current) { @@ -648,7 +672,64 @@ const [canUpload, setCanUpload] = useState(true); } }, [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 ? ( + + ) : null; const limitStatusSection = limitCards.length > 0 ? (
@@ -735,28 +816,11 @@ const [canUpload, setCanUpload] = useState(true); const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[120px]') => ( <> -
-
-
-
{content}
-
- -
+
{content}
{errorDialogNode} ); - if (!supportsCamera && !task) { - return renderWithDialog( - <> - {limitStatusSection} - - {t('upload.cameraUnsupported.message')} - - - ); - } - if (loadingTask) { return renderWithDialog(
@@ -804,35 +868,59 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ const renderPermissionNotice = () => { if (permissionState === 'granted') return null; - if (permissionState === 'unsupported') { - return ( - - {t('upload.cameraUnsupported.message')} - - ); - } - if (permissionState === 'denied' || permissionState === 'error') { - return ( - - -
{permissionMessage}
- -
-
- ); - } + + const titles: Record = { + idle: t('upload.cameraDenied.title'), + prompt: t('upload.cameraDenied.title'), + granted: '', + denied: t('upload.cameraDenied.title'), + error: t('upload.cameraError.title'), + unsupported: t('upload.cameraUnsupported.title'), + }; + + const fallbackMessages: Record = { + idle: t('upload.cameraDenied.prompt'), + prompt: t('upload.cameraDenied.prompt'), + granted: '', + denied: t('upload.cameraDenied.explanation'), + error: t('upload.cameraError.explanation'), + unsupported: t('upload.cameraUnsupported.message'), + }; + + const title = titles[permissionState]; + const description = permissionMessage ?? fallbackMessages[permissionState]; + const canRetryCamera = permissionState !== 'unsupported'; + return ( - - {t('upload.cameraDenied.prompt')} - +
+
+
+ +
+
+

{title}

+

{description}

+
+
+
+ {canRetryCamera && ( + + )} + +
+
); }; return renderWithDialog( <> -
+
+ {taskFloatingCard} +
- )} - - {showTaskOverlay && task && ( -
-
- - - {t('upload.taskInfo.badge').replace('{id}', `${task.id}`)} - - - {t(`upload.taskInfo.difficulty.${task.difficulty ?? 'medium'}`)} - -
-
-

{task.title}

-

{task.description}

-
-
- {task.instructions && ( - - {t('upload.taskInfo.instructionsPrefix')}: {task.instructions} - - )} - {emotionSlug && ( - - {t('upload.taskInfo.emotion').replace('{value}', `${task.emotion?.name || emotionSlug}`)} - - )} - {preferences.countdownEnabled && ( - - {t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)} - - )} -
+
+ + + {permissionState === 'unsupported' + ? t('upload.cameraUnsupported.title') + : t('upload.cameraDenied.title')} +
)} @@ -940,7 +984,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ )}
-
+
{uploadWarning && ( @@ -957,72 +1001,67 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ )} -
-
- - - {preferences.facingMode === 'user' && ( - +
+ + + {preferences.facingMode === 'user' && ( -
- -
- - -
+ )} +
-
+
+ {mode === 'review' && reviewPhoto ? (
)} +
-
+
+
+ + {socialChips.length > 0 && ( +
+ {socialChips.map((chip) => ( +
+ {chip.label} + {chip.value} +
+ ))} +
+ )} {permissionState !== 'granted' && renderPermissionNotice()} {limitStatusSection}