From 6062b4201b46d72b21595ce27d4cca4412c4f3ec Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 3 Feb 2026 18:59:30 +0100 Subject: [PATCH] Update guest v2 home and tasks experience --- .../Controllers/Api/EventPublicController.php | 14 + .../js/admin/i18n/locales/de/management.json | 11 + .../js/admin/i18n/locales/en/management.json | 11 + resources/js/admin/lib/brandingForm.ts | 8 + resources/js/admin/mobile/BrandingPage.tsx | 113 +++++- .../js/admin/mobile/ProfileAccountPage.tsx | 2 + .../js/guest-v2/__tests__/HomeScreen.test.tsx | 71 +++- .../guest-v2/__tests__/ScreensCopy.test.tsx | 12 +- .../guest-v2/__tests__/ShareScreen.test.tsx | 10 +- .../guest-v2/__tests__/eventBranding.test.ts | 2 + .../js/guest-v2/__tests__/statsApi.test.ts | 10 +- resources/js/guest-v2/components/AppShell.tsx | 42 +- .../components/FloatingActionButton.tsx | 9 +- .../guest-v2/components/StandaloneShell.tsx | 9 +- .../js/guest-v2/components/TaskHeroCard.tsx | 2 +- resources/js/guest-v2/hooks/usePollStats.ts | 8 +- resources/js/guest-v2/lib/eventBranding.ts | 2 + resources/js/guest-v2/screens/HomeScreen.tsx | 200 +++++++--- resources/js/guest-v2/screens/TasksScreen.tsx | 364 ++++++++++++------ resources/js/guest-v2/services/statsApi.ts | 10 +- .../js/guest/context/EventBrandingContext.tsx | 2 + resources/js/guest/demo/fixtures.ts | 2 + resources/js/guest/i18n/messages.ts | 12 + .../__tests__/UploadPageDemoMode.test.tsx | 3 + .../__tests__/UploadPageImmersive.test.tsx | 3 + .../UploadPageNavVisibility.test.tsx | 3 + resources/js/guest/polling/usePollStats.ts | 22 +- resources/js/guest/router.tsx | 2 + resources/js/guest/services/eventApi.ts | 5 + resources/js/guest/types/event-branding.ts | 1 + tests/Feature/Api/Event/EventStatsTest.php | 47 +++ 31 files changed, 753 insertions(+), 259 deletions(-) create mode 100644 tests/Feature/Api/Event/EventStatsTest.php diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index cce1de3..ee70ebe 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -1055,6 +1055,7 @@ class EventPublicController extends BaseController * heading_font: ?string, * body_font: ?string, * font_size: string, + * welcome_message: ?string, * logo_url: ?string, * logo_mode: string, * logo_value: ?string, @@ -1118,6 +1119,7 @@ class EventPublicController extends BaseController $bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']); $fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size']; $fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size']; + $welcomeMessage = $this->firstStringFromSources($sources, ['welcome_message', 'welcomeMessage']); $logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']); if (! in_array($logoMode, ['emoticon', 'upload'], true)) { @@ -1179,6 +1181,7 @@ class EventPublicController extends BaseController 'heading_font' => $headingFont, 'body_font' => $bodyFont, 'font_size' => $fontSize, + 'welcome_message' => $welcomeMessage, 'logo_url' => $logoMode === 'upload' ? $logoValue : null, 'logo_mode' => $logoMode, 'logo_value' => $logoValue, @@ -2639,6 +2642,15 @@ class EventPublicController extends BaseController ->distinct('guest_name') ->count('guest_name'); + $guestCount = DB::table('photos') + ->where('event_id', $eventId) + ->distinct('guest_name') + ->count('guest_name'); + + $likesCount = (int) DB::table('photos') + ->where('event_id', $eventId) + ->sum('likes_count'); + // Tasks solved as number of photos linked to a task (proxy metric). $tasksSolved = $engagementMode === 'photo_only' ? 0 @@ -2649,6 +2661,8 @@ class EventPublicController extends BaseController $payload = [ 'online_guests' => $onlineGuests, 'tasks_solved' => $tasksSolved, + 'guest_count' => $guestCount, + 'likes_count' => $likesCount, 'latest_photo_at' => $latestPhotoAt, 'engagement_mode' => $engagementMode, ]; diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 6be5a9e..724883f 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -792,6 +792,17 @@ "fontSizeSmall": "S", "fontSizeMedium": "M", "fontSizeLarge": "L", + "welcomeMessage": { + "title": "Willkommensnachricht", + "label": "Nachricht", + "placeholder": "Willkommen bei {eventName}!", + "hint": "Nutze {eventName} oder {name} als Platzhalter." + }, + "welcomeMessageFallback": "Willkommen bei {eventName}.", + "welcomeGuestFallback": "Gast", + "previewWelcome": "Willkommen!", + "previewGuests": "Gäste", + "previewLikes": "Likes", "logo": "Logo", "logoAlt": "Logo", "logoModeUpload": "Upload", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index e12820e..9789b50 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -788,6 +788,17 @@ "fontSizeSmall": "S", "fontSizeMedium": "M", "fontSizeLarge": "L", + "welcomeMessage": { + "title": "Welcome message", + "label": "Message", + "placeholder": "Welcome to {eventName}!", + "hint": "Use {eventName} or {name} as placeholders." + }, + "welcomeMessageFallback": "Welcome to {eventName}.", + "welcomeGuestFallback": "Guest", + "previewWelcome": "Welcome!", + "previewGuests": "Guests", + "previewLikes": "Likes", "logo": "Logo", "logoAlt": "Logo", "logoModeUpload": "Upload", diff --git a/resources/js/admin/lib/brandingForm.ts b/resources/js/admin/lib/brandingForm.ts index b8b36a7..1d8d0e9 100644 --- a/resources/js/admin/lib/brandingForm.ts +++ b/resources/js/admin/lib/brandingForm.ts @@ -17,6 +17,7 @@ export type BrandingFormValues = { buttonPrimary: string; buttonSecondary: string; linkColor: string; + welcomeMessage: string; }; export type BrandingFormDefaults = Pick< @@ -33,6 +34,7 @@ export type BrandingFormDefaults = Pick< | 'buttonPrimary' | 'buttonSecondary' | 'linkColor' + | 'welcomeMessage' | 'fontSize' | 'logoMode' | 'logoPosition' @@ -125,6 +127,11 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef const buttonPrimary = readHexColor(buttons.primary, readHexColor(branding.button_primary_color, primary)); const buttonSecondary = readHexColor(buttons.secondary, readHexColor(branding.button_secondary_color, accent)); const linkColor = readHexColor(buttons.link_color ?? buttons.linkColor, readHexColor(branding.link_color, accent)); + const welcomeMessage = typeof branding.welcome_message === 'string' + ? branding.welcome_message + : typeof branding.welcomeMessage === 'string' + ? branding.welcomeMessage + : defaults.welcomeMessage; return { primary, @@ -145,5 +152,6 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef buttonPrimary, buttonSecondary, linkColor, + welcomeMessage, }; } diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 849e85d..d27c248 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -2,14 +2,14 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Theme } from '@tamagui/core'; -import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save, Droplets, Lock } from 'lucide-react'; +import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save, Droplets, Lock, X } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { Slider } from 'tamagui'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; -import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect } from './components/FormControls'; +import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; @@ -37,6 +37,7 @@ const BRANDING_FORM_DEFAULTS = { buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor, buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor, linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor, + welcomeMessage: '', fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm', logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon', logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left', @@ -76,6 +77,9 @@ const resolveBrandingDefaults = (tenantBranding: BrandingFormValues | null) => { const bodyFont = tenantBranding.bodyFont.trim() ? tenantBranding.bodyFont : BRANDING_FORM_DEFAULTS.bodyFont; + const welcomeMessage = tenantBranding.welcomeMessage?.trim() + ? tenantBranding.welcomeMessage + : BRANDING_FORM_DEFAULTS.welcomeMessage; return { ...BRANDING_FORM_DEFAULTS, @@ -90,6 +94,7 @@ const resolveBrandingDefaults = (tenantBranding: BrandingFormValues | null) => { buttonPrimary: primary, buttonSecondary: accent, linkColor: accent, + welcomeMessage, }; }; @@ -241,6 +246,12 @@ export default function MobileBrandingPage() { const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : ''; const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : ''; const previewInitials = getInitials(previewTitle); + const previewWelcomeTemplate = previewForm.welcomeMessage.trim() + ? previewForm.welcomeMessage.trim() + : t('events.branding.welcomeMessageFallback', 'Willkommen bei {eventName}.'); + const previewWelcomeMessage = previewWelcomeTemplate + .replace('{eventName}', previewTitle) + .replace('{name}', t('events.branding.welcomeGuestFallback', 'Gast')); const previewThemeName = previewForm.mode === 'dark' ? 'guestNight' : 'guestLight'; const previewIsDark = previewThemeName === 'guestNight'; const previewVariables = { @@ -346,6 +357,7 @@ export default function MobileBrandingPage() { secondary: form.buttonSecondary, link_color: form.linkColor, }, + welcome_message: form.welcomeMessage.trim() ? form.welcomeMessage.trim() : null, logo_data_url: form.logoMode === 'upload' && logoIsDataUrl ? logoUploadValue : null, logo_url: form.logoMode === 'upload' ? (normalizedLogoPath || null) : null, logo_mode: form.logoMode, @@ -705,6 +717,48 @@ export default function MobileBrandingPage() { + + + + + + + + {t('events.branding.previewWelcome', 'Willkommen!')} + + + {previewWelcomeMessage} + + - {t('events.branding.previewStat', 'Online Guests')} + {t('events.branding.previewGuests', 'Guests')} 148 -
- {t('events.branding.previewCta', 'Fotos hochladen')} -
+ + {t('events.branding.previewLikes', 'Likes')} + + + 392 + +
+ + + + + + + + + + @@ -909,6 +975,23 @@ export default function MobileBrandingPage() { + + + {t('events.branding.welcomeMessage.title', 'Welcome message')} + + + setForm((prev) => ({ ...prev, welcomeMessage: event.target.value }))} + disabled={brandingDisabled} + /> + + + {t('events.branding.welcomeMessage.hint', 'Use {eventName} or {name} as placeholders.')} + + + {t('events.branding.logo', 'Logo')} diff --git a/resources/js/admin/mobile/ProfileAccountPage.tsx b/resources/js/admin/mobile/ProfileAccountPage.tsx index 52212f2..c4f99b0 100644 --- a/resources/js/admin/mobile/ProfileAccountPage.tsx +++ b/resources/js/admin/mobile/ProfileAccountPage.tsx @@ -54,6 +54,7 @@ const TENANT_BRANDING_DEFAULTS = { buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor, buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor, linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor, + welcomeMessage: '', fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm', logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon', logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left', @@ -263,6 +264,7 @@ export default function MobileProfileAccountPage() { body_font: brandingForm.bodyFont, font_size: brandingForm.fontSize, mode: brandingForm.mode, + welcome_message: brandingForm.welcomeMessage.trim() ? brandingForm.welcomeMessage.trim() : null, typography: { ...(typeof existingBranding.typography === 'object' ? (existingBranding.typography as Record) : {}), heading: brandingForm.headingFont, diff --git a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx index 0c8cf53..e8d31d1 100644 --- a/resources/js/guest-v2/__tests__/HomeScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/HomeScreen.test.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { EventDataProvider } from '../context/EventDataContext'; +const useEventDataMock = vi.fn(); vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn(), })); +vi.mock('../context/EventDataContext', () => ({ + useEventData: () => useEventDataMock(), +})); + vi.mock('@tamagui/stacks', () => ({ YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -29,15 +33,38 @@ vi.mock('lucide-react', () => ({ Sparkles: () => sparkles, Image: () => image, Star: () => star, + Timer: () => timer, + RefreshCw: () => refresh, Trophy: () => trophy, + X: () => x, })); vi.mock('../components/AppShell', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); -vi.mock('../services/uploadApi', () => ({ - useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }), +vi.mock('../components/TaskHeroCard', () => ({ + default: ({ task }: { task: { title?: string } | null }) => ( +
+ {task?.title} + Let's go! +
+ ), +})); + +vi.mock('../components/PhotoFrameTile', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/guest/context/EventBrandingContext', () => ({ + useEventBranding: () => ({ + branding: { welcomeMessage: '' }, + isCustom: false, + }), + useOptionalEventBranding: () => ({ + branding: { welcomeMessage: '' }, + isCustom: false, + }), })); vi.mock('../services/tasksApi', () => ({ @@ -61,14 +88,26 @@ vi.mock('../services/photosApi', () => ({ fetchGallery: vi.fn().mockResolvedValue({ data: [] }), })); +const translate = (key: string, fallback?: string) => fallback ?? key; + vi.mock('@/guest/i18n/useTranslation', () => ({ - useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, locale: 'de' }), + useTranslation: () => ({ t: translate, locale: 'de' }), })); vi.mock('@/guest/i18n/LocaleContext', () => ({ useLocale: () => ({ locale: 'de' }), })); +vi.mock('../hooks/usePollStats', () => ({ + usePollStats: () => ({ + stats: { onlineGuests: 0, tasksSolved: 0, guestCount: 0, likesCount: 0, latestPhotoAt: null }, + }), +})); + +vi.mock('../lib/useStaggeredReveal', () => ({ + useStaggeredReveal: () => 5, +})); + vi.mock('@/hooks/use-appearance', () => ({ useAppearance: () => ({ resolved: 'light' }), })); @@ -81,22 +120,26 @@ import HomeScreen from '../screens/HomeScreen'; describe('HomeScreen', () => { it('shows task hero content when tasks are enabled', async () => { - render( - - - - ); + useEventDataMock.mockReturnValue({ + tasksEnabled: true, + token: 'demo', + event: { name: 'Demo Event' }, + }); + + render(); expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument(); expect(screen.getByText("Let's go!")).toBeInTheDocument(); }); it('shows capture-ready content when tasks are disabled', () => { - render( - - - - ); + useEventDataMock.mockReturnValue({ + tasksEnabled: false, + token: 'demo', + event: { name: 'Demo Event' }, + }); + + render(); expect(screen.getByText('Capture ready')).toBeInTheDocument(); expect(screen.getByText('Upload / Take photo')).toBeInTheDocument(); diff --git a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx index fafa4ef..baa8d11 100644 --- a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx +++ b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx @@ -104,7 +104,15 @@ vi.mock('../services/emotionsApi', () => ({ })); vi.mock('../hooks/usePollStats', () => ({ - usePollStats: () => ({ stats: { onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null } }), + usePollStats: () => ({ + stats: { + onlineGuests: 0, + tasksSolved: 0, + guestCount: 0, + likesCount: 0, + latestPhotoAt: null, + }, + }), })); vi.mock('../services/qrApi', () => ({ @@ -148,7 +156,7 @@ describe('Guest v2 screens copy', () => { ); - expect(screen.getByText('Prompt quest')).toBeInTheDocument(); + expect(screen.getByText('Mission hub')).toBeInTheDocument(); }); it('renders share hub header', () => { diff --git a/resources/js/guest-v2/__tests__/ShareScreen.test.tsx b/resources/js/guest-v2/__tests__/ShareScreen.test.tsx index 0a1f78a..92a03de 100644 --- a/resources/js/guest-v2/__tests__/ShareScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/ShareScreen.test.tsx @@ -31,7 +31,15 @@ vi.mock('../context/EventDataContext', () => ({ })); vi.mock('../hooks/usePollStats', () => ({ - usePollStats: () => ({ stats: { onlineGuests: 12, tasksSolved: 0, latestPhotoAt: null } }), + usePollStats: () => ({ + stats: { + onlineGuests: 12, + tasksSolved: 0, + guestCount: 40, + likesCount: 120, + latestPhotoAt: null, + }, + }), })); vi.mock('../services/qrApi', () => ({ diff --git a/resources/js/guest-v2/__tests__/eventBranding.test.ts b/resources/js/guest-v2/__tests__/eventBranding.test.ts index 6288d40..c92066c 100644 --- a/resources/js/guest-v2/__tests__/eventBranding.test.ts +++ b/resources/js/guest-v2/__tests__/eventBranding.test.ts @@ -11,6 +11,7 @@ describe('mapEventBranding', () => { heading_font: 'Event Heading', button_radius: 16, button_primary_color: '#abcdef', + welcome_message: 'Willkommen beim Event!', palette: { surface: '#111111', }, @@ -27,5 +28,6 @@ describe('mapEventBranding', () => { expect(result?.typography?.sizePreset).toBe('l'); expect(result?.buttons?.radius).toBe(16); expect(result?.buttons?.primary).toBe('#abcdef'); + expect(result?.welcomeMessage).toBe('Willkommen beim Event!'); }); }); diff --git a/resources/js/guest-v2/__tests__/statsApi.test.ts b/resources/js/guest-v2/__tests__/statsApi.test.ts index 3bc266d..cb8f5d3 100644 --- a/resources/js/guest-v2/__tests__/statsApi.test.ts +++ b/resources/js/guest-v2/__tests__/statsApi.test.ts @@ -17,7 +17,13 @@ describe('fetchEventStats', () => { it('returns cached stats on 304', async () => { fetchMock.mockResolvedValueOnce( - new Response(JSON.stringify({ online_guests: 4, tasks_solved: 1, latest_photo_at: '2024-01-01T00:00:00Z' }), { + new Response(JSON.stringify({ + online_guests: 4, + tasks_solved: 1, + guest_count: 12, + likes_count: 48, + latest_photo_at: '2024-01-01T00:00:00Z', + }), { status: 200, headers: { ETag: '"demo"' }, }) @@ -25,6 +31,8 @@ describe('fetchEventStats', () => { const first = await fetchEventStats('demo'); expect(first.onlineGuests).toBe(4); + expect(first.guestCount).toBe(12); + expect(first.likesCount).toBe(48); fetchMock.mockResolvedValueOnce(new Response(null, { status: 304, headers: { ETag: '"demo"' } })); const second = await fetchEventStats('demo'); diff --git a/resources/js/guest-v2/components/AppShell.tsx b/resources/js/guest-v2/components/AppShell.tsx index 006df78..5ac483c 100644 --- a/resources/js/guest-v2/components/AppShell.tsx +++ b/resources/js/guest-v2/components/AppShell.tsx @@ -1,11 +1,10 @@ import React from 'react'; import { YStack } from '@tamagui/stacks'; import { Button } from '@tamagui/button'; -import { Sparkles, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react'; +import { Sparkles, Share2, Image, Camera, Settings, Home, Menu } from 'lucide-react'; import { useLocation, useNavigate } from 'react-router-dom'; import TopBar from './TopBar'; import FloatingActionButton from './FloatingActionButton'; -import FabActionRing from './FabActionRing'; import CompassHub, { type CompassAction } from './CompassHub'; import AmbientBackground from './AmbientBackground'; import NotificationSheet from './NotificationSheet'; @@ -22,7 +21,6 @@ type AppShellProps = { }; export default function AppShell({ children }: AppShellProps) { - const [fabOpen, setFabOpen] = React.useState(false); const [compassOpen, setCompassOpen] = React.useState(false); const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); @@ -37,7 +35,6 @@ export default function AppShell({ children }: AppShellProps) { const showFab = !/\/photo\/\d+/.test(location.pathname); const goTo = (path: string) => () => { - setFabOpen(false); setCompassOpen(false); setNotificationsOpen(false); setSettingsOpen(false); @@ -49,10 +46,9 @@ export default function AppShell({ children }: AppShellProps) { }; const openCompass = () => { - setFabOpen(false); setNotificationsOpen(false); setSettingsOpen(false); - setCompassOpen(true); + setCompassOpen((prev) => !prev); }; const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [ @@ -89,13 +85,6 @@ export default function AppShell({ children }: AppShellProps) { }, ]; - const fabActions = compassQuadrants.map((action) => ({ - key: action.key, - label: action.label, - icon: action.icon, - onPress: action.onPress, - })); - return ( @@ -117,13 +106,11 @@ export default function AppShell({ children }: AppShellProps) { onProfilePress={() => { setNotificationsOpen(false); setCompassOpen(false); - setFabOpen(false); setSettingsOpen(true); }} onNotificationsPress={() => { setSettingsOpen(false); setCompassOpen(false); - setFabOpen(false); setNotificationsOpen(true); }} notificationCount={notificationCenter?.unreadCount ?? 0} @@ -135,7 +122,7 @@ export default function AppShell({ children }: AppShellProps) { gap="$4" position="relative" zIndex={1} - style={{ paddingTop: '88px', paddingBottom: '112px' }} + style={{ paddingTop: '88px', paddingBottom: '142px' }} > {children} @@ -157,36 +144,27 @@ export default function AppShell({ children }: AppShellProps) { { - setCompassOpen(false); - setNotificationsOpen(false); - setSettingsOpen(false); - setFabOpen((prev) => !prev); - }} - onLongPress={openCompass} + onPress={goTo('/upload')} /> ) : null} - { + if (!onLongPress) { + return; + } longPressTriggered.current = true; - onLongPress?.(); + onLongPress(); }} position="fixed" bottom={20} @@ -49,7 +52,7 @@ export default function FloatingActionButton({ onPress, onLongPress }: FloatingA : '0 18px 32px rgba(15, 23, 42, 0.2), 0 0 0 8px rgba(255, 255, 255, 0.7)', }} > - + ); } diff --git a/resources/js/guest-v2/components/StandaloneShell.tsx b/resources/js/guest-v2/components/StandaloneShell.tsx index 9888676..93fb9ff 100644 --- a/resources/js/guest-v2/components/StandaloneShell.tsx +++ b/resources/js/guest-v2/components/StandaloneShell.tsx @@ -10,7 +10,14 @@ type StandaloneShellProps = { export default function StandaloneShell({ children, compact = false }: StandaloneShellProps) { return ( - + {children} diff --git a/resources/js/guest-v2/components/TaskHeroCard.tsx b/resources/js/guest-v2/components/TaskHeroCard.tsx index 5a792dc..6ac2ead 100644 --- a/resources/js/guest-v2/components/TaskHeroCard.tsx +++ b/resources/js/guest-v2/components/TaskHeroCard.tsx @@ -119,7 +119,7 @@ export default function TaskHeroCard({ style={{ boxShadow: bentoSurface.shadow }} > - {t('tasks.loading', 'Loading tasks...')} + {t('common.actions.loading', 'Loading...')} ); diff --git a/resources/js/guest-v2/hooks/usePollStats.ts b/resources/js/guest-v2/hooks/usePollStats.ts index efacd79..00207f2 100644 --- a/resources/js/guest-v2/hooks/usePollStats.ts +++ b/resources/js/guest-v2/hooks/usePollStats.ts @@ -2,7 +2,13 @@ import React from 'react'; import { fetchEventStats } from '../services/statsApi'; import type { EventStats } from '../services/eventApi'; -const defaultStats: EventStats = { onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null }; +const defaultStats: EventStats = { + onlineGuests: 0, + tasksSolved: 0, + guestCount: 0, + likesCount: 0, + latestPhotoAt: null, +}; export function usePollStats(eventToken: string | null, intervalMs = 10000) { const [stats, setStats] = React.useState(defaultStats); diff --git a/resources/js/guest-v2/lib/eventBranding.ts b/resources/js/guest-v2/lib/eventBranding.ts index 5f71cd5..23bbec0 100644 --- a/resources/js/guest-v2/lib/eventBranding.ts +++ b/resources/js/guest-v2/lib/eventBranding.ts @@ -37,6 +37,7 @@ export function mapEventBranding(raw?: EventBrandingPayload | null): EventBrandi const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? ''; const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? ''; const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? ''; + const welcomeMessage = raw.welcome_message ?? null; return { primaryColor: primary ?? '', @@ -70,5 +71,6 @@ export function mapEventBranding(raw?: EventBrandingPayload | null): EventBrandi }, mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto', useDefaultBranding: raw.use_default_branding ?? undefined, + welcomeMessage, }; } diff --git a/resources/js/guest-v2/screens/HomeScreen.tsx b/resources/js/guest-v2/screens/HomeScreen.tsx index 092130e..cc10459 100644 --- a/resources/js/guest-v2/screens/HomeScreen.tsx +++ b/resources/js/guest-v2/screens/HomeScreen.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { XStack, YStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; -import { Camera, Sparkles, Image as ImageIcon, Trophy, Star } from 'lucide-react'; +import { Camera, Image as ImageIcon, Trophy, Star, X } from 'lucide-react'; import AppShell from '../components/AppShell'; import PhotoFrameTile from '../components/PhotoFrameTile'; import TaskHeroCard, { type TaskHero, type TaskHeroPhoto } from '../components/TaskHeroCard'; @@ -12,7 +12,6 @@ import { buildEventPath } from '../lib/routes'; import { useStaggeredReveal } from '../lib/useStaggeredReveal'; import { usePollStats } from '../hooks/usePollStats'; import { fetchGallery } from '../services/photosApi'; -import { useUploadQueue } from '../services/uploadApi'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { useLocale } from '@/guest/i18n/LocaleContext'; @@ -20,6 +19,8 @@ import { fetchTasks, type TaskItem } from '../services/tasksApi'; import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; import { fetchEmotions } from '../services/emotionsApi'; import { getBentoSurfaceTokens } from '../lib/bento'; +import { useEventBranding } from '@/guest/context/EventBrandingContext'; +import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; type ActionTileProps = { label: string; @@ -83,12 +84,10 @@ function ActionTile({ function QuickStats({ reveal, stats, - queueCount, isDark, }: { reveal: number; - stats: { onlineGuests: number; tasksSolved: number }; - queueCount: number; + stats: { guestCount: number; likesCount: number }; isDark: boolean; }) { const { t } = useTranslation(); @@ -116,10 +115,10 @@ function QuickStats({ }} > - {stats.onlineGuests} + {stats.guestCount} - {t('home.stats.online', 'Guests online')} + {t('homeV2.stats.guestCount', 'Guests')} - {queueCount} + {stats.likesCount} - {t('homeV2.stats.uploadsQueued', 'Uploads queued')} + {t('homeV2.stats.likesCount', 'Likes')} @@ -165,14 +164,15 @@ function normalizeImageUrl(src?: string | null) { } export default function HomeScreen() { - const { tasksEnabled, token } = useEventData(); + const { tasksEnabled, token, event } = useEventData(); const navigate = useNavigate(); - const revealStage = useStaggeredReveal({ steps: 4, intervalMs: 140, delayMs: 120 }); + const revealStage = useStaggeredReveal({ steps: 5, intervalMs: 140, delayMs: 120 }); const { stats } = usePollStats(token ?? null); - const { items } = useUploadQueue(); const { t } = useTranslation(); const { locale } = useLocale(); const { isCompleted } = useGuestTaskProgress(token ?? undefined); + const { branding } = useEventBranding(); + const identity = useOptionalGuestIdentity(); const [galleryPhotos, setGalleryPhotos] = React.useState([]); const [galleryLoading, setGalleryLoading] = React.useState(false); const [galleryError, setGalleryError] = React.useState(null); @@ -181,29 +181,51 @@ export default function HomeScreen() { const [taskLoading, setTaskLoading] = React.useState(false); const [taskError, setTaskError] = React.useState(null); const [hasSwiped, setHasSwiped] = React.useState(false); - const [emotionMap, setEmotionMap] = React.useState>({}); const { isDark } = useGuestThemeVariant(); const bentoSurface = getBentoSurfaceTokens(isDark); const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.75)'; const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.08)'; const cardShadow = bentoSurface.shadow; + const welcomeStorageKey = token ? `guestWelcomeDismissed_${token}` : 'guestWelcomeDismissed'; + const [welcomeVisible, setWelcomeVisible] = React.useState(true); + const eventName = event?.name ?? t('galleryPage.hero.eventFallback', 'Event'); + const guestName = identity?.name?.trim() ? identity.name.trim() : t('home.fallbackGuestName', 'Gast'); + const welcomeTemplate = branding?.welcomeMessage?.trim() + ? branding.welcomeMessage.trim() + : t('homeV2.welcome.message', 'Welcome to {eventName}.'); + const welcomeMessage = welcomeTemplate + .replace('{eventName}', eventName) + .replace('{name}', guestName); + const dismissWelcome = React.useCallback(() => { + setWelcomeVisible(false); + if (typeof window === 'undefined') return; + try { + window.sessionStorage.setItem(welcomeStorageKey, '1'); + } catch { + // ignore storage errors + } + }, [welcomeStorageKey]); const goTo = (path: string) => () => navigate(buildEventPath(token, path)); const recentTaskIdsRef = React.useRef([]); + React.useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = window.sessionStorage.getItem(welcomeStorageKey); + setWelcomeVisible(stored !== '1'); + } catch { + setWelcomeVisible(true); + } + }, [welcomeStorageKey]); + const rings = [ - tasksEnabled - ? { - label: t('home.actions.items.tasks.label', 'Draw a task card'), - icon: , - path: '/tasks', - } - : { - label: t('home.actions.items.upload.label', 'Upload photo'), - icon: , - path: '/upload', - }, + { + label: t('home.actions.items.upload.label', 'Upload photo'), + icon: , + path: '/upload', + }, { label: t('homeV2.rings.newUploads', 'New uploads'), icon: , @@ -218,10 +240,10 @@ export default function HomeScreen() { label: t('navigation.achievements', 'Achievements'), icon: , path: '/achievements', - }, + }, ]; - const mapTaskItem = React.useCallback((task: TaskItem): TaskHero | null => { + const mapTaskItem = React.useCallback((task: TaskItem, emotionMap: Record): TaskHero | null => { const record = task as Record; const id = Number(record.id ?? record.task_id ?? 0); const title = @@ -281,7 +303,7 @@ export default function HomeScreen() { duration: Number.isFinite(duration) ? duration : null, emotion, }; - }, [emotionMap]); + }, []); const selectRandomTask = React.useCallback( (list: TaskHero[]) => { @@ -389,12 +411,8 @@ export default function HomeScreen() { nextMap[slug] = title || slug; } } - setEmotionMap(nextMap); - const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[]; + const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[]; setTasks(mapped); - if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) { - selectRandomTask(mapped); - } }) .catch((error) => { console.error('Failed to load tasks', error); @@ -405,7 +423,7 @@ export default function HomeScreen() { .finally(() => { setTaskLoading(false); }); - }, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]); + }, [locale, mapTaskItem, t, tasksEnabled, token]); React.useEffect(() => { let active = true; @@ -435,12 +453,8 @@ export default function HomeScreen() { nextMap[slug] = title || slug; } } - setEmotionMap(nextMap); - const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[]; + const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[]; setTasks(mapped); - if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) { - selectRandomTask(mapped); - } }) .catch((error) => { console.error('Failed to load tasks', error); @@ -459,9 +473,19 @@ export default function HomeScreen() { return () => { active = false; }; - }, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]); + }, [locale, mapTaskItem, t, tasksEnabled, token]); + + React.useEffect(() => { + if (!tasksEnabled || tasks.length === 0) { + setCurrentTask(null); + return; + } + if (currentTask && tasks.some((task) => task.id === currentTask.id) && !isCompleted(currentTask.id)) { + return; + } + selectRandomTask(tasks); + }, [currentTask, isCompleted, selectRandomTask, tasks, tasksEnabled]); - const queueCount = items.filter((item) => item.status !== 'done').length; const preview = React.useMemo( () => galleryPhotos.slice(0, 4).map((photo) => ({ @@ -510,25 +534,66 @@ export default function HomeScreen() { return ( - - = 1 ? 1 : 0} - y={revealStage >= 1 ? 0 : 12} - > - - {rings.map((ring) => ( - - - - ))} - - + + {welcomeVisible && welcomeMessage.trim() ? ( + = 1 ? 1 : 0} + y={revealStage >= 1 ? 0 : 12} + > + + + + {t('homeV2.welcome.label', 'Welcome')} + + + {welcomeMessage} + + + + ) : null} {tasksEnabled ? ( = 2 ? 1 : 0} y={revealStage >= 2 ? 0 : 16} > = 2 ? 1 : 0} y={revealStage >= 2 ? 0 : 16} style={{ backgroundImage: isDark @@ -586,8 +654,7 @@ export default function HomeScreen() { @@ -658,6 +725,23 @@ export default function HomeScreen() { })} + + = 5 ? 1 : 0} + y={revealStage >= 5 ? 0 : 12} + > + + {rings.map((ring) => ( + + + + ))} + + + ); diff --git a/resources/js/guest-v2/screens/TasksScreen.tsx b/resources/js/guest-v2/screens/TasksScreen.tsx index 3beafad..074cc29 100644 --- a/resources/js/guest-v2/screens/TasksScreen.tsx +++ b/resources/js/guest-v2/screens/TasksScreen.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; -import { Sparkles, Trophy, Play } from 'lucide-react'; +import { Trophy, Play } from 'lucide-react'; import AppShell from '../components/AppShell'; +import TaskHeroCard, { type TaskHero } from '../components/TaskHeroCard'; import { useEventData } from '../context/EventDataContext'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useLocale } from '@/guest/i18n/LocaleContext'; @@ -12,13 +13,11 @@ import { fetchEmotions } from '../services/emotionsApi'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { useNavigate } from 'react-router-dom'; import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; +import { getBentoSurfaceTokens } from '../lib/bento'; +import { buildEventPath } from '../lib/routes'; -type TaskItem = { - id: number; - title: string; +type TaskItem = TaskHero & { points?: number; - description?: string | null; - emotion?: string | null; }; export default function TasksScreen() { @@ -26,84 +25,159 @@ export default function TasksScreen() { const { t } = useTranslation(); const { locale } = useLocale(); const navigate = useNavigate(); - const { completedCount } = useGuestTaskProgress(token ?? undefined); + const { completedCount, isCompleted } = useGuestTaskProgress(token ?? undefined); const { isDark } = useGuestThemeVariant(); - const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; - const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)'; + const bentoSurface = getBentoSurfaceTokens(isDark); + const cardBorder = bentoSurface.borderColor; + const cardShadow = bentoSurface.shadow; const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; const [tasks, setTasks] = React.useState([]); const [highlight, setHighlight] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); - const [emotions, setEmotions] = React.useState>({}); + const [hasSwiped, setHasSwiped] = React.useState(false); const progressTotal = tasks.length || 1; const progressPercent = Math.min(100, Math.round((completedCount / progressTotal) * 100)); + const mapTaskItem = React.useCallback((task: unknown, emotionMap: Record) => { + const record = task as Record; + const id = Number(record.id ?? record.task_id ?? 0); + const title = + typeof record.title === 'string' + ? record.title + : typeof record.name === 'string' + ? record.name + : ''; + if (!id || !title) return null; + + const description = + typeof record.description === 'string' + ? record.description + : typeof record.prompt === 'string' + ? record.prompt + : null; + const instructions = + typeof record.instructions === 'string' + ? record.instructions + : typeof record.instruction === 'string' + ? record.instruction + : null; + const durationValue = record.duration ?? record.time_limit ?? record.minutes ?? null; + const duration = typeof durationValue === 'number' ? durationValue : Number(durationValue); + const emotionSlug = typeof record.emotion_slug === 'string' ? record.emotion_slug : null; + const emotionMeta = emotionSlug ? emotionMap[emotionSlug] : undefined; + + return { + id, + title, + description, + instructions, + duration: Number.isFinite(duration) ? duration : null, + emotion: emotionSlug + ? { + slug: emotionSlug, + name: emotionMeta?.name ?? emotionSlug, + emoji: emotionMeta?.emoji, + } + : undefined, + points: typeof record.points === 'number' ? record.points : undefined, + } satisfies TaskItem; + }, []); + + const loadTasks = React.useCallback(async () => { + if (!token) { + setTasks([]); + setHighlight(null); + setError(null); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const [taskList, emotionList] = await Promise.all([ + fetchTasks(token, { locale, perPage: 12 }), + fetchEmotions(token, locale), + ]); + + const emotionMap: Record = {}; + for (const emotion of emotionList) { + const record = emotion as Record; + const slug = typeof record.slug === 'string' ? record.slug : ''; + const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : ''; + const emoji = typeof record.emoji === 'string' ? record.emoji : undefined; + if (slug) { + emotionMap[slug] = { name: title || slug, emoji }; + } + } + + const mapped = taskList.map((task) => mapTaskItem(task, emotionMap)).filter(Boolean) as TaskItem[]; + setTasks(mapped); + setHighlight((prev) => { + if (!mapped.length) return null; + if (prev && mapped.some((task) => task.id === prev.id)) { + return prev; + } + return mapped[0] ?? null; + }); + } catch (err) { + console.error('Failed to load tasks', err); + setError(t('pendingUploads.error', 'Loading failed. Please try again.')); + } finally { + setLoading(false); + } + }, [locale, mapTaskItem, t, token]); + React.useEffect(() => { if (!token) { setTasks([]); setHighlight(null); + setError(null); + setLoading(false); return; } - let active = true; - setLoading(true); - setError(null); + loadTasks(); + }, [loadTasks, token]); - Promise.all([ - fetchTasks(token, { locale, perPage: 12 }), - fetchEmotions(token, locale), - ]) - .then(([taskList, emotionList]) => { - if (!active) return; + React.useEffect(() => { + if (!tasks.length) { + setHighlight(null); + return; + } + if (highlight && tasks.some((task) => task.id === highlight.id)) return; + setHighlight(tasks[0] ?? null); + }, [highlight, tasks]); - const emotionMap: Record = {}; - for (const emotion of emotionList) { - const record = emotion as Record; - const slug = typeof record.slug === 'string' ? record.slug : ''; - const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : ''; - if (slug) { - emotionMap[slug] = title || slug; - } - } - setEmotions(emotionMap); + const handleStartTask = React.useCallback(() => { + if (!highlight) return; + navigate(buildEventPath(token, `/upload?taskId=${highlight.id}`)); + }, [highlight, navigate, token]); - const mapped = taskList - .map((task) => { - const record = task as Record; - const id = Number(record.id ?? 0); - const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : ''; - if (!id || !title) return null; - return { - id, - title, - points: typeof record.points === 'number' ? record.points : undefined, - description: typeof record.description === 'string' ? record.description : null, - emotion: typeof record.emotion_slug === 'string' ? record.emotion_slug : null, - } satisfies TaskItem; - }) - .filter(Boolean) as TaskItem[]; + const handleShuffle = React.useCallback(() => { + if (!tasks.length) return; + const candidates = tasks.filter((task) => task.id !== highlight?.id); + const nextList = candidates.length ? candidates : tasks; + const next = nextList[Math.floor(Math.random() * nextList.length)]; + setHighlight(next); + setHasSwiped(true); + }, [highlight?.id, tasks]); - setTasks(mapped); - setHighlight(mapped[0] ?? null); - }) - .catch((err) => { - console.error('Failed to load tasks', err); - if (active) { - setError(t('tasks.error', 'Tasks could not be loaded.')); - } - }) - .finally(() => { - if (active) { - setLoading(false); - } - }); + const handleViewSimilar = React.useCallback(() => { + if (!highlight) return; + navigate(buildEventPath(token, `/gallery?task=${highlight.id}`)); + }, [highlight, navigate, token]); - return () => { - active = false; - }; - }, [token, locale, t]); + const handleOpenPhoto = React.useCallback( + (photoId: number) => { + if (!highlight) return; + navigate(buildEventPath(token, `/gallery?photoId=${photoId}&task=${highlight.id}`)); + }, + [highlight, navigate, token] + ); if (!tasksEnabled) { return ( @@ -111,11 +185,14 @@ export default function TasksScreen() { {t('tasks.disabled.title', 'Tasks are disabled')} @@ -134,68 +211,78 @@ export default function TasksScreen() { - - - - Prompt quest - - + + {t('tasks.page.eyebrow', 'Mission hub')} + - {highlight?.title ?? t('tasks.loading', 'Loading tasks...')} + {t('tasks.page.title', 'Your next task')} - {highlight?.description ?? t('tasks.subtitle', 'Complete this quest to unlock new prompts.')} + {t('tasks.page.subtitle', 'Pick a mood or stay spontaneous.')} - - - - {t('tasks.page.progressLabel', 'Quest progress')} - - - {highlight ? `${progressPercent}%` : '--'} - - - - - - - {t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')} + + + setHasSwiped(true)} + onStart={handleStartTask} + onShuffle={handleShuffle} + onViewSimilar={handleViewSimilar} + onRetry={loadTasks} + onOpenPhoto={handleOpenPhoto} + isCompleted={Boolean(highlight && isCompleted(highlight.id))} + photos={[]} + photosLoading={false} + /> + + + + + {t('tasks.page.progressLabel', 'Quest progress')} + + {highlight ? `${progressPercent}%` : '--'} + + + + - + + {t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')} + {error ? ( - + {error} @@ -205,13 +292,32 @@ export default function TasksScreen() { {tasks.length === 0 && loading ? ( - {t('tasks.loading', 'Loading tasks...')} + {t('common.actions.loading', 'Loading...')} + + + ) : null} + {tasks.length === 0 && !loading && !error ? ( + + + {t('tasks.page.noTasksAlert', 'No tasks available for this event yet.')} ) : null} @@ -219,10 +325,12 @@ export default function TasksScreen() { {task.title} - {task.emotion ? ( + {task.emotion?.name ? ( - {emotions[task.emotion] ?? task.emotion} + {task.emotion.name} ) : null} - - - - +{task.points ?? 0} - - + {typeof task.points === 'number' ? ( + + + + +{task.points} + + + ) : null}