Update guest v2 home and tasks experience
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-03 18:59:30 +01:00
parent 298a8375b6
commit 6062b4201b
31 changed files with 753 additions and 259 deletions

View File

@@ -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,
];

View File

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

View File

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

View File

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

View File

@@ -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() {
</XStack>
<YStack gap="$2">
<YStack
position="relative"
padding="$3"
borderRadius={14}
backgroundColor={previewSurface}
borderWidth={1}
borderColor={previewBorder}
style={{ boxShadow: previewSurfaceShadow }}
>
<YStack position="absolute" top={-10} right={-10}>
<YStack
width={28}
height={28}
borderRadius={999}
backgroundColor={previewSurface}
borderWidth={1}
borderColor={previewBorder}
alignItems="center"
justifyContent="center"
style={{
boxShadow: previewIsDark
? '0 6px 0 rgba(0, 0, 0, 0.45)'
: '0 6px 0 rgba(15, 23, 42, 0.2)',
}}
>
<X size={12} color={previewSurfaceText} />
</YStack>
</YStack>
<Text
fontWeight="700"
color={previewSurfaceText}
style={{ fontFamily: previewHeadingFont, fontSize: 14 * previewScale }}
>
{t('events.branding.previewWelcome', 'Willkommen!')}
</Text>
<Text
color={previewMutedForeground}
style={{ fontFamily: previewBodyFont, fontSize: 12 * previewScale }}
>
{previewWelcomeMessage}
</Text>
</YStack>
<YStack
padding="$3"
borderRadius={14}
@@ -744,25 +798,37 @@ export default function MobileBrandingPage() {
flex={1}
>
<Text fontSize="$2" color={previewMutedForeground}>
{t('events.branding.previewStat', 'Online Guests')}
{t('events.branding.previewGuests', 'Guests')}
</Text>
<Text fontWeight="800" color={previewSurfaceText} style={{ fontSize: 14 * previewScale }}>
148
</Text>
</YStack>
<div
style={{
padding: '8px 14px',
borderRadius: previewForm.buttonRadius,
background: previewForm.buttonStyle === 'outline' ? 'transparent' : previewButtonColor,
color: previewForm.buttonStyle === 'outline' ? previewForm.linkColor : previewButtonText,
border: previewForm.buttonStyle === 'outline' ? `1px solid ${previewForm.linkColor}` : 'none',
fontWeight: 700,
fontSize: 13 * previewScale,
}}
<YStack
padding="$2"
borderRadius="$pill"
backgroundColor={previewSurface}
borderWidth={1}
borderColor={previewBorder}
flex={1}
>
{t('events.branding.previewCta', 'Fotos hochladen')}
</div>
<Text fontSize="$2" color={previewMutedForeground}>
{t('events.branding.previewLikes', 'Likes')}
</Text>
<Text fontWeight="800" color={previewSurfaceText} style={{ fontSize: 14 * previewScale }}>
392
</Text>
</YStack>
</XStack>
<XStack gap="$2" alignItems="center">
<YStack width={64} height={48} borderRadius={12} backgroundColor={previewIconSurface} />
<YStack width={64} height={48} borderRadius={12} backgroundColor={previewIconSurface} />
<YStack width={64} height={48} borderRadius={12} backgroundColor={previewIconSurface} />
</XStack>
<XStack gap="$2">
<YStack flex={1} height={44} borderRadius={12} backgroundColor={previewIconSurface} />
<YStack flex={1} height={44} borderRadius={12} backgroundColor={previewIconSurface} />
<YStack flex={1} height={44} borderRadius={12} backgroundColor={previewIconSurface} />
</XStack>
</YStack>
@@ -909,6 +975,23 @@ export default function MobileBrandingPage() {
</XStack>
</MobileCard>
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.welcomeMessage.title', 'Welcome message')}
</Text>
<MobileField label={t('events.branding.welcomeMessage.label', 'Message')}>
<MobileTextArea
value={form.welcomeMessage}
placeholder={t('events.branding.welcomeMessage.placeholder', 'Welcome to {eventName}!')}
onChange={(event) => setForm((prev) => ({ ...prev, welcomeMessage: event.target.value }))}
disabled={brandingDisabled}
/>
</MobileField>
<Text fontSize="$xs" color={muted}>
{t('events.branding.welcomeMessage.hint', 'Use {eventName} or {name} as placeholders.')}
</Text>
</MobileCard>
<MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.logo', 'Logo')}

View File

@@ -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<string, unknown>) : {}),
heading: brandingForm.headingFont,

View File

@@ -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 }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -29,15 +33,38 @@ vi.mock('lucide-react', () => ({
Sparkles: () => <span>sparkles</span>,
Image: () => <span>image</span>,
Star: () => <span>star</span>,
Timer: () => <span>timer</span>,
RefreshCw: () => <span>refresh</span>,
Trophy: () => <span>trophy</span>,
X: () => <span>x</span>,
}));
vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../services/uploadApi', () => ({
useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }),
vi.mock('../components/TaskHeroCard', () => ({
default: ({ task }: { task: { title?: string } | null }) => (
<div>
<span>{task?.title}</span>
<span>Let&apos;s go!</span>
</div>
),
}));
vi.mock('../components/PhotoFrameTile', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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(
<EventDataProvider tasksEnabledFallback>
<HomeScreen />
</EventDataProvider>
);
useEventDataMock.mockReturnValue({
tasksEnabled: true,
token: 'demo',
event: { name: 'Demo Event' },
});
render(<HomeScreen />);
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(
<EventDataProvider tasksEnabledFallback={false}>
<HomeScreen />
</EventDataProvider>
);
useEventDataMock.mockReturnValue({
tasksEnabled: false,
token: 'demo',
event: { name: 'Demo Event' },
});
render(<HomeScreen />);
expect(screen.getByText('Capture ready')).toBeInTheDocument();
expect(screen.getByText('Upload / Take photo')).toBeInTheDocument();

View File

@@ -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', () => {
</EventDataProvider>
);
expect(screen.getByText('Prompt quest')).toBeInTheDocument();
expect(screen.getByText('Mission hub')).toBeInTheDocument();
});
it('renders share hub header', () => {

View File

@@ -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', () => ({

View File

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

View File

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

View File

@@ -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 (
<AmbientBackground>
<YStack minHeight="100vh" position="relative">
@@ -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}
</YStack>
@@ -157,36 +144,27 @@ export default function AppShell({ children }: AppShellProps) {
<Home size={16} color={actionIconColor} />
</Button>
<FloatingActionButton
onPress={() => {
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setFabOpen((prev) => !prev);
}}
onLongPress={openCompass}
onPress={goTo('/upload')}
/>
<Button
size="$3"
size="$4"
circular
position="fixed"
bottom={28}
left="calc(50% + 48px)"
left="calc(50% + 52px)"
zIndex={1100}
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.75)' : 'rgba(255, 255, 255, 0.9)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.12)'}
onPress={location.pathname.includes('/gallery') && tasksEnabled ? goTo('/tasks') : goTo('/gallery')}
width={54}
height={54}
onPress={openCompass}
style={{ boxShadow: isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)' }}
>
{location.pathname.includes('/gallery') && tasksEnabled ? (
<Sparkles size={16} color={actionIconColor} />
) : (
<Image size={16} color={actionIconColor} />
)}
<Menu size={18} color={actionIconColor} />
</Button>
</>
) : null}
<FabActionRing open={fabOpen} onOpenChange={setFabOpen} actions={fabActions} />
<CompassHub
open={compassOpen}
onOpenChange={setCompassOpen}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '@tamagui/button';
import { Flower } from 'lucide-react';
import { Camera } from 'lucide-react';
import { useGuestThemeVariant } from '../lib/guestTheme';
type FloatingActionButtonProps = {
@@ -25,8 +25,11 @@ export default function FloatingActionButton({ onPress, onLongPress }: FloatingA
longPressTriggered.current = false;
}}
onLongPress={() => {
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)',
}}
>
<Flower size={26} color="white" />
<Camera size={26} color="white" />
</Button>
);
}

View File

@@ -10,7 +10,14 @@ type StandaloneShellProps = {
export default function StandaloneShell({ children, compact = false }: StandaloneShellProps) {
return (
<AmbientBackground>
<YStack minHeight="100vh" padding="$4" paddingTop={compact ? '$4' : '$6'} paddingBottom="$6" gap="$4">
<YStack
minHeight="100vh"
padding="$4"
paddingTop={compact ? '$4' : '$6'}
paddingBottom="$6"
gap="$4"
style={{ paddingBottom: 'calc(var(--space-6) + 30px)' }}
>
{children}
</YStack>
</AmbientBackground>

View File

@@ -119,7 +119,7 @@ export default function TaskHeroCard({
style={{ boxShadow: bentoSurface.shadow }}
>
<Text fontSize="$3" opacity={0.7}>
{t('tasks.loading', 'Loading tasks...')}
{t('common.actions.loading', 'Loading...')}
</Text>
</YStack>
);

View File

@@ -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<EventStats>(defaultStats);

View File

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

View File

@@ -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({
}}
>
<Text fontSize="$4" fontWeight="$8">
{stats.onlineGuests}
{stats.guestCount}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
{t('home.stats.online', 'Guests online')}
{t('homeV2.stats.guestCount', 'Guests')}
</Text>
</YStack>
<YStack
@@ -137,10 +136,10 @@ function QuickStats({
}}
>
<Text fontSize="$4" fontWeight="$8">
{queueCount}
{stats.likesCount}
</Text>
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
{t('homeV2.stats.uploadsQueued', 'Uploads queued')}
{t('homeV2.stats.likesCount', 'Likes')}
</Text>
</YStack>
</XStack>
@@ -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<TaskPhoto[]>([]);
const [galleryLoading, setGalleryLoading] = React.useState(false);
const [galleryError, setGalleryError] = React.useState<string | null>(null);
@@ -181,29 +181,51 @@ export default function HomeScreen() {
const [taskLoading, setTaskLoading] = React.useState(false);
const [taskError, setTaskError] = React.useState<string | null>(null);
const [hasSwiped, setHasSwiped] = React.useState(false);
const [emotionMap, setEmotionMap] = React.useState<Record<string, string>>({});
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<number[]>([]);
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: <Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/tasks',
}
: {
label: t('home.actions.items.upload.label', 'Upload photo'),
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/upload',
},
{
label: t('home.actions.items.upload.label', 'Upload photo'),
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/upload',
},
{
label: t('homeV2.rings.newUploads', 'New uploads'),
icon: <ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
@@ -218,10 +240,10 @@ export default function HomeScreen() {
label: t('navigation.achievements', 'Achievements'),
icon: <Trophy size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/achievements',
},
},
];
const mapTaskItem = React.useCallback((task: TaskItem): TaskHero | null => {
const mapTaskItem = React.useCallback((task: TaskItem, emotionMap: Record<string, string>): TaskHero | null => {
const record = task as Record<string, unknown>;
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<GalleryPreview[]>(
() =>
galleryPhotos.slice(0, 4).map((photo) => ({
@@ -510,25 +534,66 @@ export default function HomeScreen() {
return (
<AppShell>
<YStack gap="$4">
<YStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 1 ? 1 : 0}
y={revealStage >= 1 ? 0 : 12}
>
<XStack gap="$2" flexWrap="nowrap">
{rings.map((ring) => (
<YStack key={ring.label} flex={1} minWidth={0}>
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
</YStack>
))}
</XStack>
</YStack>
<YStack gap="$4">
{welcomeVisible && welcomeMessage.trim() ? (
<YStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 1 ? 1 : 0}
y={revealStage >= 1 ? 0 : 12}
>
<YStack
position="relative"
padding="$4"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: cardShadow,
}}
>
<Button
unstyled
position="absolute"
top={-12}
right={-12}
width={34}
height={34}
borderRadius={999}
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={bentoSurface.borderColor}
alignItems="center"
justifyContent="center"
onPress={dismissWelcome}
pressStyle={{ y: 2 }}
style={{
boxShadow: isDark ? '0 6px 0 rgba(0, 0, 0, 0.45)' : '0 6px 0 rgba(15, 23, 42, 0.2)',
}}
aria-label={t('common.actions.close', 'Close')}
>
<X size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<Text fontSize="$2" color="$color" opacity={0.75} textTransform="uppercase" letterSpacing={1.4}>
{t('homeV2.welcome.label', 'Welcome')}
</Text>
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
{welcomeMessage}
</Text>
</YStack>
</YStack>
) : null}
{tasksEnabled ? (
<YStack
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 2 ? 1 : 0}
y={revealStage >= 2 ? 0 : 16}
>
<TaskHeroCard
@@ -558,6 +623,9 @@ export default function HomeScreen() {
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 2 ? 1 : 0}
y={revealStage >= 2 ? 0 : 16}
style={{
backgroundImage: isDark
@@ -586,8 +654,7 @@ export default function HomeScreen() {
<QuickStats
reveal={revealStage}
stats={{ onlineGuests: stats.onlineGuests, tasksSolved: stats.tasksSolved }}
queueCount={queueCount}
stats={{ guestCount: stats.guestCount, likesCount: stats.likesCount }}
isDark={isDark}
/>
@@ -658,6 +725,23 @@ export default function HomeScreen() {
})}
</XStack>
</YStack>
<YStack
gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 5 ? 1 : 0}
y={revealStage >= 5 ? 0 : 12}
>
<XStack gap="$2" flexWrap="nowrap">
{rings.map((ring) => (
<YStack key={ring.label} flex={1} minWidth={0}>
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
</YStack>
))}
</XStack>
</YStack>
<YStack height={70} />
</YStack>
</AppShell>
);

View File

@@ -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<TaskItem[]>([]);
const [highlight, setHighlight] = React.useState<TaskItem | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [emotions, setEmotions] = React.useState<Record<string, string>>({});
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<string, { name: string; emoji?: string }>) => {
const record = task as Record<string, unknown>;
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<string, { name: string; emoji?: string }> = {};
for (const emotion of emotionList) {
const record = emotion as Record<string, unknown>;
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<string, string> = {};
for (const emotion of emotionList) {
const record = emotion as Record<string, unknown>;
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<string, unknown>;
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() {
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{ boxShadow: bentoSurface.shadow }}
>
<Text fontSize="$4" fontWeight="$7">
{t('tasks.disabled.title', 'Tasks are disabled')}
@@ -134,68 +211,78 @@ export default function TasksScreen() {
<YStack gap="$4">
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderRadius="$bentoLg"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
gap="$3"
style={{
backgroundImage: isDark
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.22), rgba(79, 209, 255, 0.1))'
: 'linear-gradient(135deg, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), color-mix(in oklab, var(--guest-secondary, #F43F5E) 10%, white))',
boxShadow: isDark ? '0 22px 46px rgba(2, 6, 23, 0.45)' : '0 18px 32px rgba(15, 23, 42, 0.12)',
}}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{ boxShadow: cardShadow }}
>
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color="#FF4FD8" />
<Text fontSize="$4" fontWeight="$7">
Prompt quest
</Text>
</XStack>
<Text fontSize="$2" fontWeight="$7" textTransform="uppercase" letterSpacing={2} color="$color" opacity={0.7}>
{t('tasks.page.eyebrow', 'Mission hub')}
</Text>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8">
{highlight?.title ?? t('tasks.loading', 'Loading tasks...')}
{t('tasks.page.title', 'Your next task')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{highlight?.description ?? t('tasks.subtitle', 'Complete this quest to unlock new prompts.')}
{t('tasks.page.subtitle', 'Pick a mood or stay spontaneous.')}
</Text>
<YStack gap="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" fontWeight="$7">
{t('tasks.page.progressLabel', 'Quest progress')}
</Text>
<Text fontSize="$2" fontWeight="$7">
{highlight ? `${progressPercent}%` : '--'}
</Text>
</XStack>
<YStack backgroundColor="$muted" borderRadius="$pill" height={10} overflow="hidden">
<YStack backgroundColor="$primary" width={highlight ? `${progressPercent}%` : '20%'} height={10} />
</YStack>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')}
</YStack>
<TaskHeroCard
task={highlight}
loading={loading}
error={error}
hasSwiped={hasSwiped}
onSwiped={() => setHasSwiped(true)}
onStart={handleStartTask}
onShuffle={handleShuffle}
onViewSimilar={handleViewSimilar}
onRetry={loadTasks}
onOpenPhoto={handleOpenPhoto}
isCompleted={Boolean(highlight && isCompleted(highlight.id))}
photos={[]}
photosLoading={false}
/>
<YStack
padding="$3"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{ boxShadow: cardShadow }}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" fontWeight="$7">
{t('tasks.page.progressLabel', 'Quest progress')}
</Text>
<Text fontSize="$2" fontWeight="$7">
{highlight ? `${progressPercent}%` : '--'}
</Text>
</XStack>
<YStack backgroundColor="$muted" borderRadius="$pill" height={10} overflow="hidden">
<YStack backgroundColor="$primary" width={highlight ? `${progressPercent}%` : '20%'} height={10} />
</YStack>
<Button
size="$4"
backgroundColor="$primary"
borderRadius="$pill"
disabled={!highlight || loading}
onPress={() => {
if (highlight) navigate(`./${highlight.id}`);
}}
>
{loading ? t('tasks.loading', 'Loading tasks...') : t('tasks.start', 'Start quest')}
</Button>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')}
</Text>
</YStack>
{error ? (
<YStack
padding="$3"
borderRadius="$card"
backgroundColor="rgba(248, 113, 113, 0.12)"
borderRadius="$bento"
backgroundColor="rgba(248, 113, 113, 0.1)"
borderWidth={1}
borderColor="rgba(248, 113, 113, 0.4)"
>
<Text fontSize="$2" color="#FEE2E2">
<Text fontSize="$2" color="$color" opacity={0.8}>
{error}
</Text>
</YStack>
@@ -205,13 +292,32 @@ export default function TasksScreen() {
{tasks.length === 0 && loading ? (
<YStack
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
style={{ boxShadow: cardShadow }}
>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t('tasks.loading', 'Loading tasks...')}
{t('common.actions.loading', 'Loading...')}
</Text>
</YStack>
) : null}
{tasks.length === 0 && !loading && !error ? (
<YStack
padding="$3"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
style={{ boxShadow: cardShadow }}
>
<Text fontSize="$2" color="$color" opacity={0.8}>
{t('tasks.page.noTasksAlert', 'No tasks available for this event yet.')}
</Text>
</YStack>
) : null}
@@ -219,10 +325,12 @@ export default function TasksScreen() {
<YStack
key={task.id}
padding="$3"
borderRadius="$card"
backgroundColor="$surface"
borderRadius="$bento"
backgroundColor={bentoSurface.backgroundColor}
borderWidth={1}
borderColor={cardBorder}
borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2"
style={{
boxShadow: cardShadow,
@@ -233,18 +341,20 @@ export default function TasksScreen() {
<Text fontSize="$4" fontWeight="$7">
{task.title}
</Text>
{task.emotion ? (
{task.emotion?.name ? (
<Text fontSize="$2" color="$color" opacity={0.7}>
{emotions[task.emotion] ?? task.emotion}
{task.emotion.name}
</Text>
) : null}
</YStack>
<XStack alignItems="center" gap="$2">
<Trophy size={16} color="#FDE047" />
<Text fontSize="$2" fontWeight="$7">
+{task.points ?? 0}
</Text>
</XStack>
{typeof task.points === 'number' ? (
<XStack alignItems="center" gap="$2">
<Trophy size={16} color="#FDE047" />
<Text fontSize="$2" fontWeight="$7">
+{task.points}
</Text>
</XStack>
) : null}
</XStack>
<Button
size="$3"
@@ -252,7 +362,7 @@ export default function TasksScreen() {
borderRadius="$pill"
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => navigate(`./${task.id}`)}
onPress={() => navigate(buildEventPath(token, `/upload?taskId=${task.id}`))}
>
<XStack alignItems="center" gap="$2">
<Play size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />

View File

@@ -6,7 +6,13 @@ const statsCache = new Map<string, { etag: string | null; data: EventStats }>();
export async function fetchEventStats(eventToken: string): Promise<EventStats> {
const cached = statsCache.get(eventToken);
const response = await fetchJson<{ online_guests?: number; tasks_solved?: number; latest_photo_at?: string | null }>(
const response = await fetchJson<{
online_guests?: number;
tasks_solved?: number;
guest_count?: number;
likes_count?: number;
latest_photo_at?: string | null;
}>(
`/api/v1/events/${encodeURIComponent(eventToken)}/stats`,
{
headers: {
@@ -24,6 +30,8 @@ export async function fetchEventStats(eventToken: string): Promise<EventStats> {
const stats: EventStats = {
onlineGuests: response.data?.online_guests ?? 0,
tasksSolved: response.data?.tasks_solved ?? 0,
guestCount: response.data?.guest_count ?? 0,
likesCount: response.data?.likes_count ?? 0,
latestPhotoAt: response.data?.latest_photo_at ?? null,
};

View File

@@ -14,6 +14,7 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
backgroundColor: '#FFF6F2',
fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
logoUrl: null,
welcomeMessage: null,
palette: {
primary: '#E94B5A',
secondary: '#F7C7CF',
@@ -111,6 +112,7 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
},
mode: input.mode ?? 'auto',
useDefaultBranding: input.useDefaultBranding ?? undefined,
welcomeMessage: input.welcomeMessage ?? null,
};
}

View File

@@ -103,6 +103,8 @@ export const demoFixtures: DemoFixtures = {
stats: {
onlineGuests: 42,
tasksSolved: 187,
guestCount: 128,
likesCount: 980,
latestPhotoAt: now(),
},
eventPackage: {

View File

@@ -295,6 +295,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
},
homeV2: {
welcome: {
label: 'Willkommen',
message: 'Willkommen, {name}!',
},
rings: {
newUploads: 'Neue Uploads',
topMoments: 'Top-Momente',
@@ -314,6 +318,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
stats: {
uploadsQueued: 'Uploads in Warteschlange',
guestCount: 'Gäste',
likesCount: 'Likes',
},
galleryPreview: {
title: 'Galerie-Vorschau',
@@ -1202,6 +1208,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
},
homeV2: {
welcome: {
label: 'Welcome',
message: 'Welcome, {name}!',
},
rings: {
newUploads: 'New uploads',
topMoments: 'Top moments',
@@ -1221,6 +1231,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
stats: {
uploadsQueued: 'Uploads queued',
guestCount: 'Guests',
likesCount: 'Likes',
},
galleryPreview: {
title: 'Gallery preview',

View File

@@ -39,6 +39,9 @@ vi.mock('../../context/EventStatsContext', () => ({
useEventStats: () => ({
latestPhotoAt: null,
onlineGuests: 2,
tasksSolved: 0,
guestCount: 2,
likesCount: 0,
}),
}));

View File

@@ -35,6 +35,9 @@ vi.mock('../../context/EventStatsContext', () => ({
useEventStats: () => ({
latestPhotoAt: null,
onlineGuests: 0,
tasksSolved: 0,
guestCount: 0,
likesCount: 0,
}),
}));

View File

@@ -35,6 +35,9 @@ vi.mock('../../context/EventStatsContext', () => ({
useEventStats: () => ({
latestPhotoAt: null,
onlineGuests: 2,
tasksSolved: 0,
guestCount: 2,
likesCount: 0,
}),
}));

View File

@@ -3,17 +3,27 @@ import { useEffect, useRef, useState } from 'react';
export type EventStats = {
onlineGuests: number;
tasksSolved: number;
guestCount: number;
likesCount: number;
latestPhotoAt: string | null;
};
type StatsResponse = {
online_guests?: number;
tasks_solved?: number;
guest_count?: number;
likes_count?: number;
latest_photo_at?: string;
};
export function usePollStats(eventKey: string | null | undefined) {
const [data, setData] = useState<EventStats>({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
const [data, setData] = useState<EventStats>({
onlineGuests: 0,
tasksSolved: 0,
guestCount: 0,
likesCount: 0,
latestPhotoAt: null,
});
const [loading, setLoading] = useState(true);
const timer = useRef<number | null>(null);
const [visible, setVisible] = useState(
@@ -30,7 +40,13 @@ export function usePollStats(eventKey: string | null | undefined) {
if (res.status === 304) return;
if (!res.ok) {
if (res.status === 404) {
setData({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
setData({
onlineGuests: 0,
tasksSolved: 0,
guestCount: 0,
likesCount: 0,
latestPhotoAt: null,
});
}
return;
}
@@ -38,6 +54,8 @@ export function usePollStats(eventKey: string | null | undefined) {
setData({
onlineGuests: json.online_guests ?? 0,
tasksSolved: json.tasks_solved ?? 0,
guestCount: json.guest_count ?? 0,
likesCount: json.likes_count ?? 0,
latestPhotoAt: json.latest_photo_at ?? null,
});
} finally {

View File

@@ -227,6 +227,7 @@ function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | nu
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 ?? '',
@@ -260,6 +261,7 @@ function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | nu
},
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
useDefaultBranding: raw.use_default_branding ?? undefined,
welcomeMessage,
};
}

View File

@@ -11,6 +11,7 @@ export interface EventBrandingPayload {
heading_font?: string | null;
body_font?: string | null;
font_size?: 's' | 'm' | 'l' | null;
welcome_message?: string | null;
icon?: string | null;
logo_mode?: 'emoticon' | 'upload' | null;
logo_value?: string | null;
@@ -123,6 +124,8 @@ export interface EventPackage {
export interface EventStats {
onlineGuests: number;
tasksSolved: number;
guestCount: number;
likesCount: number;
latestPhotoAt: string | null;
}
@@ -339,6 +342,8 @@ export async function fetchStats(eventKey: string): Promise<EventStats> {
return {
onlineGuests: json.online_guests ?? json.onlineGuests ?? 0,
tasksSolved: json.tasks_solved ?? json.tasksSolved ?? 0,
guestCount: json.guest_count ?? json.guestCount ?? 0,
likesCount: json.likes_count ?? json.likesCount ?? 0,
latestPhotoAt: json.latest_photo_at ?? json.latestPhotoAt ?? null,
};
}

View File

@@ -5,6 +5,7 @@ export interface EventBranding {
backgroundColor: string;
fontFamily: string | null;
logoUrl: string | null;
welcomeMessage?: string | null;
// Extended branding shape
useDefaultBranding?: boolean;

View File

@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature\Api\Event;
use App\Models\Event;
use App\Models\Photo;
use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventStatsTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_guest_and_like_counts(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
Photo::factory()->create([
'event_id' => $event->id,
'guest_name' => 'Alex',
'likes_count' => 3,
]);
Photo::factory()->create([
'event_id' => $event->id,
'guest_name' => 'Sam',
'likes_count' => 5,
]);
Photo::factory()->create([
'event_id' => $event->id,
'guest_name' => 'Alex',
'likes_count' => 2,
]);
$token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'stats']);
$response = $this->getJson('/api/v1/events/'.$token->plain_token.'/stats');
$response->assertOk();
$response->assertJsonPath('guest_count', 2);
$response->assertJsonPath('likes_count', 10);
}
}