referenzen auf "credits" entfernt. Kamera-Seite schicker gemacht

This commit is contained in:
Codex Agent
2025-11-13 10:44:16 +01:00
parent a4feb431fb
commit d9a63a6209
14 changed files with 373 additions and 219 deletions

View File

@@ -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']);

View File

@@ -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.');
}
} }

View File

@@ -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"
}, },

View File

@@ -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": {

View File

@@ -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."
} }
} }
} }

View File

@@ -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": {

View File

@@ -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"
}, },

View File

@@ -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": {

View File

@@ -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."
} }
} }
} }

View File

@@ -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": {

View File

@@ -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.'),
}, },
]; ];

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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}