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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({
<NavLink
to={to}
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
${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 isUploadActive = currentPath.startsWith(`${base}/upload`);
const compact = isUploadActive;
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="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">
<Home className="h-5 w-5" aria-hidden />
<span>{labels.home}</span>
</div>
</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">
<CheckSquare className="h-5 w-5" aria-hidden />
<span>{labels.tasks}</span>
@@ -88,7 +96,7 @@ export default function BottomNav() {
<Link
to={`${base}/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'
}`}
style={{
@@ -100,13 +108,23 @@ export default function BottomNav() {
</Link>
<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">
<Trophy className="h-5 w-5" aria-hidden />
<span>{labels.achievements}</span>
</div>
</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">
<GalleryHorizontal className="h-5 w-5" aria-hidden />
<span>{labels.gallery}</span>

View File

@@ -172,7 +172,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
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<LocaleCode, NestedMessages> = {
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: {

View File

@@ -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();
@@ -138,6 +137,7 @@ const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(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 ? (
<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 ? (
<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]') => (
<>
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
<main className="px-4">
<div className={wrapperClassName}>{content}</div>
</main>
<BottomNav />
</div>
{errorDialogNode}
</>
);
if (!supportsCamera && !task) {
return renderWithDialog(
<>
{limitStatusSection}
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</>
);
}
if (loadingTask) {
return renderWithDialog(
<div className="flex flex-col items-center justify-center gap-4 text-center">
@@ -804,34 +868,58 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
const renderPermissionNotice = () => {
if (permissionState === 'granted') return null;
if (permissionState === 'unsupported') {
const titles: Record<PermissionState, string> = {
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<PermissionState, string> = {
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 (
<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">
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
);
}
if (permissionState === 'denied' || permissionState === 'error') {
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">
<div>{permissionMessage}</div>
<Button size="sm" variant="outline" onClick={startCamera}>
{t('upload.buttons.tryAgain')}
<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">
<div className="flex items-center gap-3">
<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>
</AlertDescription>
</Alert>
);
}
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">
<AlertDescription>{t('upload.cameraDenied.prompt')}</AlertDescription>
</Alert>
)}
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
{t('upload.galleryButton')}
</Button>
</div>
</div>
);
};
return renderWithDialog(
<>
<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">
<video
@@ -857,57 +945,13 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)}
{!isCameraActive && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70 text-center text-sm">
<Camera className="mb-3 h-8 w-8 text-pink-400" />
<p className="max-w-xs text-white/90">
{t('upload.cameraInactive').replace(
'{hint}',
(permissionMessage ?? t('upload.cameraInactiveHint').replace('{label}', t('upload.buttons.startCamera')))
)}
</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 && (
<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="h-4 w-4 text-pink-400" />
<span>
{t('upload.taskInfo.instructionsPrefix')}: {task.instructions}
{permissionState === 'unsupported'
? t('upload.cameraUnsupported.title')
: t('upload.cameraDenied.title')}
</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>
)}
@@ -940,7 +984,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)}
</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 && (
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
<AlertDescription className="text-xs">
@@ -957,72 +1001,67 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</Alert>
)}
<div className="flex flex-wrap justify-between gap-3">
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap items-center justify-center gap-2 text-xs font-medium uppercase tracking-wide text-white/80">
<Button
size="icon"
size="sm"
variant={preferences.gridEnabled ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.gridEnabled && 'bg-white text-black'
)}
onClick={handleToggleGrid}
>
<Grid3X3 className="h-5 w-5" />
<span className="sr-only">{t('upload.controls.toggleGrid')}</span>
<Grid3X3 className="mr-1 h-3.5 w-3.5" />
{t('upload.controls.toggleGrid')}
</Button>
<Button
size="icon"
size="sm"
variant={preferences.countdownEnabled ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.countdownEnabled && 'bg-white text-black'
)}
onClick={handleToggleCountdown}
>
<span className="text-sm font-semibold">{preferences.countdownSeconds}s</span>
<span className="sr-only">{t('upload.controls.toggleCountdown')}</span>
{t('upload.countdownLabel').replace('{seconds}', `${preferences.countdownSeconds}`)}
</Button>
{preferences.facingMode === 'user' && (
<Button
size="icon"
size="sm"
variant={preferences.mirrorFrontPreview ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.mirrorFrontPreview && 'bg-white text-black'
)}
onClick={handleToggleMirror}
>
<span className="text-sm font-semibold">?</span>
<span className="sr-only">{t('upload.controls.toggleMirror')}</span>
{t('upload.controls.toggleMirror')}
</Button>
)}
<Button
size="icon"
size="sm"
variant={preferences.flashPreferred ? 'default' : 'secondary'}
className="h-10 w-10 rounded-full bg-white/15 text-white"
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
preferences.flashPreferred && 'bg-white text-black'
)}
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>
{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 className="flex flex-wrap gap-2">
<div className="flex items-center justify-center gap-6">
<Button
variant="secondary"
size="sm"
className="rounded-full border-white/30 bg-white/10 text-white"
onClick={handleSwitchCamera}
>
<RotateCcw className="mr-1 h-4 w-4" />
{t('upload.switchCamera')}
</Button>
<Button
variant="secondary"
size="sm"
className="rounded-full border-white/30 bg-white/10 text-white"
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="mr-1 h-4 w-4" />
{t('upload.galleryButton')}
<ImagePlus className="h-6 w-6" />
<span className="sr-only">{t('upload.galleryButton')}</span>
</Button>
</div>
</div>
<div className="flex items-center justify-center">
{mode === 'review' && reviewPhoto ? (
<div className="flex w-full max-w-md flex-col gap-3 sm:flex-row">
<Button variant="secondary" className="flex-1" onClick={handleRetake}>
@@ -1035,17 +1074,42 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
) : (
<Button
size="lg"
className="h-16 w-16 rounded-full border-4 border-white/40 bg-white/90 text-black shadow-xl"
onClick={beginCapture}
disabled={!isCameraActive || mode === 'countdown'}
className="flex h-20 w-20 items-center justify-center rounded-full border-4 border-white/40 bg-white text-black shadow-2xl"
onClick={handlePrimaryAction}
disabled={mode === 'countdown' || mode === 'uploading'}
>
<Camera className="h-7 w-7" />
<span className="sr-only">{t('upload.captureButton')}</span>
<Camera className="h-8 w-8" />
<span className="sr-only">
{isCameraActive ? t('upload.captureButton') : t('upload.buttons.startCamera')}
</span>
</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>
</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()}
{limitStatusSection}