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, * heading_font: ?string,
* body_font: ?string, * body_font: ?string,
* font_size: string, * font_size: string,
* welcome_message: ?string,
* logo_url: ?string, * logo_url: ?string,
* logo_mode: string, * logo_mode: string,
* logo_value: ?string, * logo_value: ?string,
@@ -1118,6 +1119,7 @@ class EventPublicController extends BaseController
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']); $bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size']; $fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $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']); $logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
if (! in_array($logoMode, ['emoticon', 'upload'], true)) { if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
@@ -1179,6 +1181,7 @@ class EventPublicController extends BaseController
'heading_font' => $headingFont, 'heading_font' => $headingFont,
'body_font' => $bodyFont, 'body_font' => $bodyFont,
'font_size' => $fontSize, 'font_size' => $fontSize,
'welcome_message' => $welcomeMessage,
'logo_url' => $logoMode === 'upload' ? $logoValue : null, 'logo_url' => $logoMode === 'upload' ? $logoValue : null,
'logo_mode' => $logoMode, 'logo_mode' => $logoMode,
'logo_value' => $logoValue, 'logo_value' => $logoValue,
@@ -2639,6 +2642,15 @@ class EventPublicController extends BaseController
->distinct('guest_name') ->distinct('guest_name')
->count('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). // Tasks solved as number of photos linked to a task (proxy metric).
$tasksSolved = $engagementMode === 'photo_only' $tasksSolved = $engagementMode === 'photo_only'
? 0 ? 0
@@ -2649,6 +2661,8 @@ class EventPublicController extends BaseController
$payload = [ $payload = [
'online_guests' => $onlineGuests, 'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved, 'tasks_solved' => $tasksSolved,
'guest_count' => $guestCount,
'likes_count' => $likesCount,
'latest_photo_at' => $latestPhotoAt, 'latest_photo_at' => $latestPhotoAt,
'engagement_mode' => $engagementMode, 'engagement_mode' => $engagementMode,
]; ];

View File

@@ -792,6 +792,17 @@
"fontSizeSmall": "S", "fontSizeSmall": "S",
"fontSizeMedium": "M", "fontSizeMedium": "M",
"fontSizeLarge": "L", "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", "logo": "Logo",
"logoAlt": "Logo", "logoAlt": "Logo",
"logoModeUpload": "Upload", "logoModeUpload": "Upload",

View File

@@ -788,6 +788,17 @@
"fontSizeSmall": "S", "fontSizeSmall": "S",
"fontSizeMedium": "M", "fontSizeMedium": "M",
"fontSizeLarge": "L", "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", "logo": "Logo",
"logoAlt": "Logo", "logoAlt": "Logo",
"logoModeUpload": "Upload", "logoModeUpload": "Upload",

View File

@@ -17,6 +17,7 @@ export type BrandingFormValues = {
buttonPrimary: string; buttonPrimary: string;
buttonSecondary: string; buttonSecondary: string;
linkColor: string; linkColor: string;
welcomeMessage: string;
}; };
export type BrandingFormDefaults = Pick< export type BrandingFormDefaults = Pick<
@@ -33,6 +34,7 @@ export type BrandingFormDefaults = Pick<
| 'buttonPrimary' | 'buttonPrimary'
| 'buttonSecondary' | 'buttonSecondary'
| 'linkColor' | 'linkColor'
| 'welcomeMessage'
| 'fontSize' | 'fontSize'
| 'logoMode' | 'logoMode'
| 'logoPosition' | 'logoPosition'
@@ -125,6 +127,11 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef
const buttonPrimary = readHexColor(buttons.primary, readHexColor(branding.button_primary_color, primary)); const buttonPrimary = readHexColor(buttons.primary, readHexColor(branding.button_primary_color, primary));
const buttonSecondary = readHexColor(buttons.secondary, readHexColor(branding.button_secondary_color, accent)); 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 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 { return {
primary, primary,
@@ -145,5 +152,6 @@ export function extractBrandingForm(settings: unknown, defaults: BrandingFormDef
buttonPrimary, buttonPrimary,
buttonSecondary, buttonSecondary,
linkColor, linkColor,
welcomeMessage,
}; };
} }

View File

@@ -2,14 +2,14 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Theme } from '@tamagui/core'; 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 { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { Slider } from 'tamagui'; import { Slider } from 'tamagui';
import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; 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 { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { ApiError, getApiErrorMessage } from '../lib/apiError';
@@ -37,6 +37,7 @@ const BRANDING_FORM_DEFAULTS = {
buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor, buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor,
buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor, buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor,
linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor, linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor,
welcomeMessage: '',
fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm', fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm',
logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon', logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon',
logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left', logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left',
@@ -76,6 +77,9 @@ const resolveBrandingDefaults = (tenantBranding: BrandingFormValues | null) => {
const bodyFont = tenantBranding.bodyFont.trim() const bodyFont = tenantBranding.bodyFont.trim()
? tenantBranding.bodyFont ? tenantBranding.bodyFont
: BRANDING_FORM_DEFAULTS.bodyFont; : BRANDING_FORM_DEFAULTS.bodyFont;
const welcomeMessage = tenantBranding.welcomeMessage?.trim()
? tenantBranding.welcomeMessage
: BRANDING_FORM_DEFAULTS.welcomeMessage;
return { return {
...BRANDING_FORM_DEFAULTS, ...BRANDING_FORM_DEFAULTS,
@@ -90,6 +94,7 @@ const resolveBrandingDefaults = (tenantBranding: BrandingFormValues | null) => {
buttonPrimary: primary, buttonPrimary: primary,
buttonSecondary: accent, buttonSecondary: accent,
linkColor: accent, linkColor: accent,
welcomeMessage,
}; };
}; };
@@ -241,6 +246,12 @@ export default function MobileBrandingPage() {
const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : ''; const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : '';
const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : ''; const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : '';
const previewInitials = getInitials(previewTitle); 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 previewThemeName = previewForm.mode === 'dark' ? 'guestNight' : 'guestLight';
const previewIsDark = previewThemeName === 'guestNight'; const previewIsDark = previewThemeName === 'guestNight';
const previewVariables = { const previewVariables = {
@@ -346,6 +357,7 @@ export default function MobileBrandingPage() {
secondary: form.buttonSecondary, secondary: form.buttonSecondary,
link_color: form.linkColor, link_color: form.linkColor,
}, },
welcome_message: form.welcomeMessage.trim() ? form.welcomeMessage.trim() : null,
logo_data_url: form.logoMode === 'upload' && logoIsDataUrl ? logoUploadValue : null, logo_data_url: form.logoMode === 'upload' && logoIsDataUrl ? logoUploadValue : null,
logo_url: form.logoMode === 'upload' ? (normalizedLogoPath || null) : null, logo_url: form.logoMode === 'upload' ? (normalizedLogoPath || null) : null,
logo_mode: form.logoMode, logo_mode: form.logoMode,
@@ -705,6 +717,48 @@ export default function MobileBrandingPage() {
</XStack> </XStack>
<YStack gap="$2"> <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 <YStack
padding="$3" padding="$3"
borderRadius={14} borderRadius={14}
@@ -744,25 +798,37 @@ export default function MobileBrandingPage() {
flex={1} flex={1}
> >
<Text fontSize="$2" color={previewMutedForeground}> <Text fontSize="$2" color={previewMutedForeground}>
{t('events.branding.previewStat', 'Online Guests')} {t('events.branding.previewGuests', 'Guests')}
</Text> </Text>
<Text fontWeight="800" color={previewSurfaceText} style={{ fontSize: 14 * previewScale }}> <Text fontWeight="800" color={previewSurfaceText} style={{ fontSize: 14 * previewScale }}>
148 148
</Text> </Text>
</YStack> </YStack>
<div <YStack
style={{ padding="$2"
padding: '8px 14px', borderRadius="$pill"
borderRadius: previewForm.buttonRadius, backgroundColor={previewSurface}
background: previewForm.buttonStyle === 'outline' ? 'transparent' : previewButtonColor, borderWidth={1}
color: previewForm.buttonStyle === 'outline' ? previewForm.linkColor : previewButtonText, borderColor={previewBorder}
border: previewForm.buttonStyle === 'outline' ? `1px solid ${previewForm.linkColor}` : 'none', flex={1}
fontWeight: 700,
fontSize: 13 * previewScale,
}}
> >
{t('events.branding.previewCta', 'Fotos hochladen')} <Text fontSize="$2" color={previewMutedForeground}>
</div> {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> </XStack>
</YStack> </YStack>
@@ -909,6 +975,23 @@ export default function MobileBrandingPage() {
</XStack> </XStack>
</MobileCard> </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"> <MobileCard gap="$3">
<Text fontSize="$md" fontWeight="800" color={textStrong}> <Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.branding.logo', 'Logo')} {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, buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor,
buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor, buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor,
linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor, linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor,
welcomeMessage: '',
fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm', fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm',
logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon', logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon',
logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left', logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left',
@@ -263,6 +264,7 @@ export default function MobileProfileAccountPage() {
body_font: brandingForm.bodyFont, body_font: brandingForm.bodyFont,
font_size: brandingForm.fontSize, font_size: brandingForm.fontSize,
mode: brandingForm.mode, mode: brandingForm.mode,
welcome_message: brandingForm.welcomeMessage.trim() ? brandingForm.welcomeMessage.trim() : null,
typography: { typography: {
...(typeof existingBranding.typography === 'object' ? (existingBranding.typography as Record<string, unknown>) : {}), ...(typeof existingBranding.typography === 'object' ? (existingBranding.typography as Record<string, unknown>) : {}),
heading: brandingForm.headingFont, heading: brandingForm.headingFont,

View File

@@ -1,12 +1,16 @@
import React from 'react'; import React from 'react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { EventDataProvider } from '../context/EventDataContext'; const useEventDataMock = vi.fn();
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(), useNavigate: () => vi.fn(),
})); }));
vi.mock('../context/EventDataContext', () => ({
useEventData: () => useEventDataMock(),
}));
vi.mock('@tamagui/stacks', () => ({ vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ 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>, Sparkles: () => <span>sparkles</span>,
Image: () => <span>image</span>, Image: () => <span>image</span>,
Star: () => <span>star</span>, Star: () => <span>star</span>,
Timer: () => <span>timer</span>,
RefreshCw: () => <span>refresh</span>,
Trophy: () => <span>trophy</span>, Trophy: () => <span>trophy</span>,
X: () => <span>x</span>,
})); }));
vi.mock('../components/AppShell', () => ({ vi.mock('../components/AppShell', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
})); }));
vi.mock('../services/uploadApi', () => ({ vi.mock('../components/TaskHeroCard', () => ({
useUploadQueue: () => ({ items: [], loading: false, refresh: vi.fn() }), 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', () => ({ vi.mock('../services/tasksApi', () => ({
@@ -61,14 +88,26 @@ vi.mock('../services/photosApi', () => ({
fetchGallery: vi.fn().mockResolvedValue({ data: [] }), fetchGallery: vi.fn().mockResolvedValue({ data: [] }),
})); }));
const translate = (key: string, fallback?: string) => fallback ?? key;
vi.mock('@/guest/i18n/useTranslation', () => ({ 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', () => ({ vi.mock('@/guest/i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }), 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', () => ({ vi.mock('@/hooks/use-appearance', () => ({
useAppearance: () => ({ resolved: 'light' }), useAppearance: () => ({ resolved: 'light' }),
})); }));
@@ -81,22 +120,26 @@ import HomeScreen from '../screens/HomeScreen';
describe('HomeScreen', () => { describe('HomeScreen', () => {
it('shows task hero content when tasks are enabled', async () => { it('shows task hero content when tasks are enabled', async () => {
render( useEventDataMock.mockReturnValue({
<EventDataProvider tasksEnabledFallback> tasksEnabled: true,
<HomeScreen /> token: 'demo',
</EventDataProvider> event: { name: 'Demo Event' },
); });
render(<HomeScreen />);
expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument(); expect(await screen.findByText('Capture the dancefloor')).toBeInTheDocument();
expect(screen.getByText("Let's go!")).toBeInTheDocument(); expect(screen.getByText("Let's go!")).toBeInTheDocument();
}); });
it('shows capture-ready content when tasks are disabled', () => { it('shows capture-ready content when tasks are disabled', () => {
render( useEventDataMock.mockReturnValue({
<EventDataProvider tasksEnabledFallback={false}> tasksEnabled: false,
<HomeScreen /> token: 'demo',
</EventDataProvider> event: { name: 'Demo Event' },
); });
render(<HomeScreen />);
expect(screen.getByText('Capture ready')).toBeInTheDocument(); expect(screen.getByText('Capture ready')).toBeInTheDocument();
expect(screen.getByText('Upload / Take photo')).toBeInTheDocument(); expect(screen.getByText('Upload / Take photo')).toBeInTheDocument();

View File

@@ -104,7 +104,15 @@ vi.mock('../services/emotionsApi', () => ({
})); }));
vi.mock('../hooks/usePollStats', () => ({ 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', () => ({ vi.mock('../services/qrApi', () => ({
@@ -148,7 +156,7 @@ describe('Guest v2 screens copy', () => {
</EventDataProvider> </EventDataProvider>
); );
expect(screen.getByText('Prompt quest')).toBeInTheDocument(); expect(screen.getByText('Mission hub')).toBeInTheDocument();
}); });
it('renders share hub header', () => { it('renders share hub header', () => {

View File

@@ -31,7 +31,15 @@ vi.mock('../context/EventDataContext', () => ({
})); }));
vi.mock('../hooks/usePollStats', () => ({ 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', () => ({ vi.mock('../services/qrApi', () => ({

View File

@@ -11,6 +11,7 @@ describe('mapEventBranding', () => {
heading_font: 'Event Heading', heading_font: 'Event Heading',
button_radius: 16, button_radius: 16,
button_primary_color: '#abcdef', button_primary_color: '#abcdef',
welcome_message: 'Willkommen beim Event!',
palette: { palette: {
surface: '#111111', surface: '#111111',
}, },
@@ -27,5 +28,6 @@ describe('mapEventBranding', () => {
expect(result?.typography?.sizePreset).toBe('l'); expect(result?.typography?.sizePreset).toBe('l');
expect(result?.buttons?.radius).toBe(16); expect(result?.buttons?.radius).toBe(16);
expect(result?.buttons?.primary).toBe('#abcdef'); 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 () => { it('returns cached stats on 304', async () => {
fetchMock.mockResolvedValueOnce( 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, status: 200,
headers: { ETag: '"demo"' }, headers: { ETag: '"demo"' },
}) })
@@ -25,6 +31,8 @@ describe('fetchEventStats', () => {
const first = await fetchEventStats('demo'); const first = await fetchEventStats('demo');
expect(first.onlineGuests).toBe(4); 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"' } })); fetchMock.mockResolvedValueOnce(new Response(null, { status: 304, headers: { ETag: '"demo"' } }));
const second = await fetchEventStats('demo'); const second = await fetchEventStats('demo');

View File

@@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import { YStack } from '@tamagui/stacks'; import { YStack } from '@tamagui/stacks';
import { Button } from '@tamagui/button'; 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 { useLocation, useNavigate } from 'react-router-dom';
import TopBar from './TopBar'; import TopBar from './TopBar';
import FloatingActionButton from './FloatingActionButton'; import FloatingActionButton from './FloatingActionButton';
import FabActionRing from './FabActionRing';
import CompassHub, { type CompassAction } from './CompassHub'; import CompassHub, { type CompassAction } from './CompassHub';
import AmbientBackground from './AmbientBackground'; import AmbientBackground from './AmbientBackground';
import NotificationSheet from './NotificationSheet'; import NotificationSheet from './NotificationSheet';
@@ -22,7 +21,6 @@ type AppShellProps = {
}; };
export default function AppShell({ children }: AppShellProps) { export default function AppShell({ children }: AppShellProps) {
const [fabOpen, setFabOpen] = React.useState(false);
const [compassOpen, setCompassOpen] = React.useState(false); const [compassOpen, setCompassOpen] = React.useState(false);
const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = 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 showFab = !/\/photo\/\d+/.test(location.pathname);
const goTo = (path: string) => () => { const goTo = (path: string) => () => {
setFabOpen(false);
setCompassOpen(false); setCompassOpen(false);
setNotificationsOpen(false); setNotificationsOpen(false);
setSettingsOpen(false); setSettingsOpen(false);
@@ -49,10 +46,9 @@ export default function AppShell({ children }: AppShellProps) {
}; };
const openCompass = () => { const openCompass = () => {
setFabOpen(false);
setNotificationsOpen(false); setNotificationsOpen(false);
setSettingsOpen(false); setSettingsOpen(false);
setCompassOpen(true); setCompassOpen((prev) => !prev);
}; };
const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [ 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 ( return (
<AmbientBackground> <AmbientBackground>
<YStack minHeight="100vh" position="relative"> <YStack minHeight="100vh" position="relative">
@@ -117,13 +106,11 @@ export default function AppShell({ children }: AppShellProps) {
onProfilePress={() => { onProfilePress={() => {
setNotificationsOpen(false); setNotificationsOpen(false);
setCompassOpen(false); setCompassOpen(false);
setFabOpen(false);
setSettingsOpen(true); setSettingsOpen(true);
}} }}
onNotificationsPress={() => { onNotificationsPress={() => {
setSettingsOpen(false); setSettingsOpen(false);
setCompassOpen(false); setCompassOpen(false);
setFabOpen(false);
setNotificationsOpen(true); setNotificationsOpen(true);
}} }}
notificationCount={notificationCenter?.unreadCount ?? 0} notificationCount={notificationCenter?.unreadCount ?? 0}
@@ -135,7 +122,7 @@ export default function AppShell({ children }: AppShellProps) {
gap="$4" gap="$4"
position="relative" position="relative"
zIndex={1} zIndex={1}
style={{ paddingTop: '88px', paddingBottom: '112px' }} style={{ paddingTop: '88px', paddingBottom: '142px' }}
> >
{children} {children}
</YStack> </YStack>
@@ -157,36 +144,27 @@ export default function AppShell({ children }: AppShellProps) {
<Home size={16} color={actionIconColor} /> <Home size={16} color={actionIconColor} />
</Button> </Button>
<FloatingActionButton <FloatingActionButton
onPress={() => { onPress={goTo('/upload')}
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setFabOpen((prev) => !prev);
}}
onLongPress={openCompass}
/> />
<Button <Button
size="$3" size="$4"
circular circular
position="fixed" position="fixed"
bottom={28} bottom={28}
left="calc(50% + 48px)" left="calc(50% + 52px)"
zIndex={1100} zIndex={1100}
backgroundColor={isDark ? 'rgba(12, 16, 32, 0.75)' : 'rgba(255, 255, 255, 0.9)'} backgroundColor={isDark ? 'rgba(12, 16, 32, 0.75)' : 'rgba(255, 255, 255, 0.9)'}
borderWidth={1} borderWidth={1}
borderColor={isDark ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.12)'} 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)' }} 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 ? ( <Menu size={18} color={actionIconColor} />
<Sparkles size={16} color={actionIconColor} />
) : (
<Image size={16} color={actionIconColor} />
)}
</Button> </Button>
</> </>
) : null} ) : null}
<FabActionRing open={fabOpen} onOpenChange={setFabOpen} actions={fabActions} />
<CompassHub <CompassHub
open={compassOpen} open={compassOpen}
onOpenChange={setCompassOpen} onOpenChange={setCompassOpen}

View File

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

View File

@@ -10,7 +10,14 @@ type StandaloneShellProps = {
export default function StandaloneShell({ children, compact = false }: StandaloneShellProps) { export default function StandaloneShell({ children, compact = false }: StandaloneShellProps) {
return ( return (
<AmbientBackground> <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} {children}
</YStack> </YStack>
</AmbientBackground> </AmbientBackground>

View File

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

View File

@@ -2,7 +2,13 @@ import React from 'react';
import { fetchEventStats } from '../services/statsApi'; import { fetchEventStats } from '../services/statsApi';
import type { EventStats } from '../services/eventApi'; 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) { export function usePollStats(eventToken: string | null, intervalMs = 10000) {
const [stats, setStats] = React.useState<EventStats>(defaultStats); 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 buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? '';
const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? ''; const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? '';
const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? ''; const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? '';
const welcomeMessage = raw.welcome_message ?? null;
return { return {
primaryColor: primary ?? '', primaryColor: primary ?? '',
@@ -70,5 +71,6 @@ export function mapEventBranding(raw?: EventBrandingPayload | null): EventBrandi
}, },
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto', mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
useDefaultBranding: raw.use_default_branding ?? undefined, 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 { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button'; 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 AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile'; import PhotoFrameTile from '../components/PhotoFrameTile';
import TaskHeroCard, { type TaskHero, type TaskHeroPhoto } from '../components/TaskHeroCard'; import TaskHeroCard, { type TaskHero, type TaskHeroPhoto } from '../components/TaskHeroCard';
@@ -12,7 +12,6 @@ import { buildEventPath } from '../lib/routes';
import { useStaggeredReveal } from '../lib/useStaggeredReveal'; import { useStaggeredReveal } from '../lib/useStaggeredReveal';
import { usePollStats } from '../hooks/usePollStats'; import { usePollStats } from '../hooks/usePollStats';
import { fetchGallery } from '../services/photosApi'; import { fetchGallery } from '../services/photosApi';
import { useUploadQueue } from '../services/uploadApi';
import { useTranslation } from '@/guest/i18n/useTranslation'; import { useTranslation } from '@/guest/i18n/useTranslation';
import { useGuestThemeVariant } from '../lib/guestTheme'; import { useGuestThemeVariant } from '../lib/guestTheme';
import { useLocale } from '@/guest/i18n/LocaleContext'; import { useLocale } from '@/guest/i18n/LocaleContext';
@@ -20,6 +19,8 @@ import { fetchTasks, type TaskItem } from '../services/tasksApi';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { fetchEmotions } from '../services/emotionsApi'; import { fetchEmotions } from '../services/emotionsApi';
import { getBentoSurfaceTokens } from '../lib/bento'; import { getBentoSurfaceTokens } from '../lib/bento';
import { useEventBranding } from '@/guest/context/EventBrandingContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
type ActionTileProps = { type ActionTileProps = {
label: string; label: string;
@@ -83,12 +84,10 @@ function ActionTile({
function QuickStats({ function QuickStats({
reveal, reveal,
stats, stats,
queueCount,
isDark, isDark,
}: { }: {
reveal: number; reveal: number;
stats: { onlineGuests: number; tasksSolved: number }; stats: { guestCount: number; likesCount: number };
queueCount: number;
isDark: boolean; isDark: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -116,10 +115,10 @@ function QuickStats({
}} }}
> >
<Text fontSize="$4" fontWeight="$8"> <Text fontSize="$4" fontWeight="$8">
{stats.onlineGuests} {stats.guestCount}
</Text> </Text>
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}> <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> </Text>
</YStack> </YStack>
<YStack <YStack
@@ -137,10 +136,10 @@ function QuickStats({
}} }}
> >
<Text fontSize="$4" fontWeight="$8"> <Text fontSize="$4" fontWeight="$8">
{queueCount} {stats.likesCount}
</Text> </Text>
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}> <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> </Text>
</YStack> </YStack>
</XStack> </XStack>
@@ -165,14 +164,15 @@ function normalizeImageUrl(src?: string | null) {
} }
export default function HomeScreen() { export default function HomeScreen() {
const { tasksEnabled, token } = useEventData(); const { tasksEnabled, token, event } = useEventData();
const navigate = useNavigate(); 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 { stats } = usePollStats(token ?? null);
const { items } = useUploadQueue();
const { t } = useTranslation(); const { t } = useTranslation();
const { locale } = useLocale(); const { locale } = useLocale();
const { isCompleted } = useGuestTaskProgress(token ?? undefined); const { isCompleted } = useGuestTaskProgress(token ?? undefined);
const { branding } = useEventBranding();
const identity = useOptionalGuestIdentity();
const [galleryPhotos, setGalleryPhotos] = React.useState<TaskPhoto[]>([]); const [galleryPhotos, setGalleryPhotos] = React.useState<TaskPhoto[]>([]);
const [galleryLoading, setGalleryLoading] = React.useState(false); const [galleryLoading, setGalleryLoading] = React.useState(false);
const [galleryError, setGalleryError] = React.useState<string | null>(null); const [galleryError, setGalleryError] = React.useState<string | null>(null);
@@ -181,25 +181,47 @@ export default function HomeScreen() {
const [taskLoading, setTaskLoading] = React.useState(false); const [taskLoading, setTaskLoading] = React.useState(false);
const [taskError, setTaskError] = React.useState<string | null>(null); const [taskError, setTaskError] = React.useState<string | null>(null);
const [hasSwiped, setHasSwiped] = React.useState(false); const [hasSwiped, setHasSwiped] = React.useState(false);
const [emotionMap, setEmotionMap] = React.useState<Record<string, string>>({});
const { isDark } = useGuestThemeVariant(); const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark); const bentoSurface = getBentoSurfaceTokens(isDark);
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.75)'; 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 mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.08)';
const cardShadow = bentoSurface.shadow; 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 goTo = (path: string) => () => navigate(buildEventPath(token, path));
const recentTaskIdsRef = React.useRef<number[]>([]); const recentTaskIdsRef = React.useRef<number[]>([]);
const rings = [ React.useEffect(() => {
tasksEnabled if (typeof window === 'undefined') return;
? { try {
label: t('home.actions.items.tasks.label', 'Draw a task card'), const stored = window.sessionStorage.getItem(welcomeStorageKey);
icon: <Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />, setWelcomeVisible(stored !== '1');
path: '/tasks', } catch {
setWelcomeVisible(true);
} }
: { }, [welcomeStorageKey]);
const rings = [
{
label: t('home.actions.items.upload.label', 'Upload photo'), label: t('home.actions.items.upload.label', 'Upload photo'),
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />, icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
path: '/upload', path: '/upload',
@@ -221,7 +243,7 @@ export default function HomeScreen() {
}, },
]; ];
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 record = task as Record<string, unknown>;
const id = Number(record.id ?? record.task_id ?? 0); const id = Number(record.id ?? record.task_id ?? 0);
const title = const title =
@@ -281,7 +303,7 @@ export default function HomeScreen() {
duration: Number.isFinite(duration) ? duration : null, duration: Number.isFinite(duration) ? duration : null,
emotion, emotion,
}; };
}, [emotionMap]); }, []);
const selectRandomTask = React.useCallback( const selectRandomTask = React.useCallback(
(list: TaskHero[]) => { (list: TaskHero[]) => {
@@ -389,12 +411,8 @@ export default function HomeScreen() {
nextMap[slug] = title || slug; nextMap[slug] = title || slug;
} }
} }
setEmotionMap(nextMap); const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[];
const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[];
setTasks(mapped); setTasks(mapped);
if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) {
selectRandomTask(mapped);
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load tasks', error); console.error('Failed to load tasks', error);
@@ -405,7 +423,7 @@ export default function HomeScreen() {
.finally(() => { .finally(() => {
setTaskLoading(false); setTaskLoading(false);
}); });
}, [currentTask, locale, mapTaskItem, selectRandomTask, t, tasksEnabled, token]); }, [locale, mapTaskItem, t, tasksEnabled, token]);
React.useEffect(() => { React.useEffect(() => {
let active = true; let active = true;
@@ -435,12 +453,8 @@ export default function HomeScreen() {
nextMap[slug] = title || slug; nextMap[slug] = title || slug;
} }
} }
setEmotionMap(nextMap); const mapped = taskList.map((task) => mapTaskItem(task, nextMap)).filter(Boolean) as TaskHero[];
const mapped = taskList.map(mapTaskItem).filter(Boolean) as TaskHero[];
setTasks(mapped); setTasks(mapped);
if (!currentTask || !mapped.some((task) => task.id === currentTask.id)) {
selectRandomTask(mapped);
}
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load tasks', error); console.error('Failed to load tasks', error);
@@ -459,9 +473,19 @@ export default function HomeScreen() {
return () => { return () => {
active = false; 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[]>( const preview = React.useMemo<GalleryPreview[]>(
() => () =>
galleryPhotos.slice(0, 4).map((photo) => ({ galleryPhotos.slice(0, 4).map((photo) => ({
@@ -511,6 +535,7 @@ export default function HomeScreen() {
return ( return (
<AppShell> <AppShell>
<YStack gap="$4"> <YStack gap="$4">
{welcomeVisible && welcomeMessage.trim() ? (
<YStack <YStack
gap="$3" gap="$3"
animation="slow" animation="slow"
@@ -518,17 +543,57 @@ export default function HomeScreen() {
opacity={revealStage >= 1 ? 1 : 0} opacity={revealStage >= 1 ? 1 : 0}
y={revealStage >= 1 ? 0 : 12} y={revealStage >= 1 ? 0 : 12}
> >
<XStack gap="$2" flexWrap="nowrap"> <YStack
{rings.map((ring) => ( position="relative"
<YStack key={ring.label} flex={1} minWidth={0}> padding="$4"
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} /> 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>
))}
</XStack>
</YStack> </YStack>
) : null}
{tasksEnabled ? ( {tasksEnabled ? (
<YStack <YStack
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 2 ? 1 : 0}
y={revealStage >= 2 ? 0 : 16} y={revealStage >= 2 ? 0 : 16}
> >
<TaskHeroCard <TaskHeroCard
@@ -558,6 +623,9 @@ export default function HomeScreen() {
borderColor={bentoSurface.borderColor} borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor} borderBottomColor={bentoSurface.borderBottomColor}
gap="$3" gap="$3"
animation="slow"
animateOnly={['transform', 'opacity']}
opacity={revealStage >= 2 ? 1 : 0}
y={revealStage >= 2 ? 0 : 16} y={revealStage >= 2 ? 0 : 16}
style={{ style={{
backgroundImage: isDark backgroundImage: isDark
@@ -586,8 +654,7 @@ export default function HomeScreen() {
<QuickStats <QuickStats
reveal={revealStage} reveal={revealStage}
stats={{ onlineGuests: stats.onlineGuests, tasksSolved: stats.tasksSolved }} stats={{ guestCount: stats.guestCount, likesCount: stats.likesCount }}
queueCount={queueCount}
isDark={isDark} isDark={isDark}
/> />
@@ -658,6 +725,23 @@ export default function HomeScreen() {
})} })}
</XStack> </XStack>
</YStack> </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> </YStack>
</AppShell> </AppShell>
); );

View File

@@ -2,8 +2,9 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button'; import { Button } from '@tamagui/button';
import { Sparkles, Trophy, Play } from 'lucide-react'; import { Trophy, Play } from 'lucide-react';
import AppShell from '../components/AppShell'; import AppShell from '../components/AppShell';
import TaskHeroCard, { type TaskHero } from '../components/TaskHeroCard';
import { useEventData } from '../context/EventDataContext'; import { useEventData } from '../context/EventDataContext';
import { useTranslation } from '@/guest/i18n/useTranslation'; import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext'; import { useLocale } from '@/guest/i18n/LocaleContext';
@@ -12,13 +13,11 @@ import { fetchEmotions } from '../services/emotionsApi';
import { useGuestThemeVariant } from '../lib/guestTheme'; import { useGuestThemeVariant } from '../lib/guestTheme';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes';
type TaskItem = { type TaskItem = TaskHero & {
id: number;
title: string;
points?: number; points?: number;
description?: string | null;
emotion?: string | null;
}; };
export default function TasksScreen() { export default function TasksScreen() {
@@ -26,84 +25,159 @@ export default function TasksScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { locale } = useLocale(); const { locale } = useLocale();
const navigate = useNavigate(); const navigate = useNavigate();
const { completedCount } = useGuestTaskProgress(token ?? undefined); const { completedCount, isCompleted } = useGuestTaskProgress(token ?? undefined);
const { isDark } = useGuestThemeVariant(); const { isDark } = useGuestThemeVariant();
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; const bentoSurface = getBentoSurfaceTokens(isDark);
const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)'; const cardBorder = bentoSurface.borderColor;
const cardShadow = bentoSurface.shadow;
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; 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 mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [tasks, setTasks] = React.useState<TaskItem[]>([]); const [tasks, setTasks] = React.useState<TaskItem[]>([]);
const [highlight, setHighlight] = React.useState<TaskItem | null>(null); const [highlight, setHighlight] = React.useState<TaskItem | null>(null);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); 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 progressTotal = tasks.length || 1;
const progressPercent = Math.min(100, Math.round((completedCount / progressTotal) * 100)); 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(() => { React.useEffect(() => {
if (!token) { if (!token) {
setTasks([]); setTasks([]);
setHighlight(null); setHighlight(null);
setError(null);
setLoading(false);
return; return;
} }
let active = true; loadTasks();
setLoading(true); }, [loadTasks, token]);
setError(null);
Promise.all([ React.useEffect(() => {
fetchTasks(token, { locale, perPage: 12 }), if (!tasks.length) {
fetchEmotions(token, locale), setHighlight(null);
]) return;
.then(([taskList, emotionList]) => {
if (!active) return;
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;
} }
} if (highlight && tasks.some((task) => task.id === highlight.id)) return;
setEmotions(emotionMap); setHighlight(tasks[0] ?? null);
}, [highlight, tasks]);
const mapped = taskList const handleStartTask = React.useCallback(() => {
.map((task) => { if (!highlight) return;
const record = task as Record<string, unknown>; navigate(buildEventPath(token, `/upload?taskId=${highlight.id}`));
const id = Number(record.id ?? 0); }, [highlight, navigate, token]);
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[];
setTasks(mapped); const handleShuffle = React.useCallback(() => {
setHighlight(mapped[0] ?? null); if (!tasks.length) return;
}) const candidates = tasks.filter((task) => task.id !== highlight?.id);
.catch((err) => { const nextList = candidates.length ? candidates : tasks;
console.error('Failed to load tasks', err); const next = nextList[Math.floor(Math.random() * nextList.length)];
if (active) { setHighlight(next);
setError(t('tasks.error', 'Tasks could not be loaded.')); setHasSwiped(true);
} }, [highlight?.id, tasks]);
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => { const handleViewSimilar = React.useCallback(() => {
active = false; if (!highlight) return;
}; navigate(buildEventPath(token, `/gallery?task=${highlight.id}`));
}, [token, locale, t]); }, [highlight, navigate, token]);
const handleOpenPhoto = React.useCallback(
(photoId: number) => {
if (!highlight) return;
navigate(buildEventPath(token, `/gallery?photoId=${photoId}&task=${highlight.id}`));
},
[highlight, navigate, token]
);
if (!tasksEnabled) { if (!tasksEnabled) {
return ( return (
@@ -111,11 +185,14 @@ export default function TasksScreen() {
<YStack gap="$4"> <YStack gap="$4">
<YStack <YStack
padding="$4" padding="$4"
borderRadius="$card" borderRadius="$bento"
backgroundColor="$surface" backgroundColor={bentoSurface.backgroundColor}
borderWidth={1} borderWidth={1}
borderColor={cardBorder} borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2" gap="$2"
style={{ boxShadow: bentoSurface.shadow }}
> >
<Text fontSize="$4" fontWeight="$7"> <Text fontSize="$4" fontWeight="$7">
{t('tasks.disabled.title', 'Tasks are disabled')} {t('tasks.disabled.title', 'Tasks are disabled')}
@@ -134,31 +211,53 @@ export default function TasksScreen() {
<YStack gap="$4"> <YStack gap="$4">
<YStack <YStack
padding="$4" padding="$4"
borderRadius="$card" borderRadius="$bentoLg"
backgroundColor="$surface" backgroundColor={bentoSurface.backgroundColor}
borderWidth={1} borderWidth={1}
borderColor={cardBorder} borderBottomWidth={3}
gap="$3" borderColor={bentoSurface.borderColor}
style={{ borderBottomColor={bentoSurface.borderBottomColor}
backgroundImage: isDark gap="$2"
? 'linear-gradient(135deg, rgba(255, 79, 216, 0.22), rgba(79, 209, 255, 0.1))' style={{ boxShadow: cardShadow }}
: '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)',
}}
> >
<XStack alignItems="center" gap="$2"> <Text fontSize="$2" fontWeight="$7" textTransform="uppercase" letterSpacing={2} color="$color" opacity={0.7}>
<Sparkles size={18} color="#FF4FD8" /> {t('tasks.page.eyebrow', 'Mission hub')}
<Text fontSize="$4" fontWeight="$7">
Prompt quest
</Text> </Text>
</XStack>
<Text fontSize="$7" fontFamily="$display" fontWeight="$8"> <Text fontSize="$7" fontFamily="$display" fontWeight="$8">
{highlight?.title ?? t('tasks.loading', 'Loading tasks...')} {t('tasks.page.title', 'Your next task')}
</Text> </Text>
<Text fontSize="$2" color="$color" opacity={0.7}> <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> </Text>
<YStack gap="$2"> </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"> <XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" fontWeight="$7"> <Text fontSize="$2" fontWeight="$7">
{t('tasks.page.progressLabel', 'Quest progress')} {t('tasks.page.progressLabel', 'Quest progress')}
@@ -174,28 +273,16 @@ export default function TasksScreen() {
{t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')} {t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')}
</Text> </Text>
</YStack> </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>
</YStack>
{error ? ( {error ? (
<YStack <YStack
padding="$3" padding="$3"
borderRadius="$card" borderRadius="$bento"
backgroundColor="rgba(248, 113, 113, 0.12)" backgroundColor="rgba(248, 113, 113, 0.1)"
borderWidth={1} borderWidth={1}
borderColor="rgba(248, 113, 113, 0.4)" borderColor="rgba(248, 113, 113, 0.4)"
> >
<Text fontSize="$2" color="#FEE2E2"> <Text fontSize="$2" color="$color" opacity={0.8}>
{error} {error}
</Text> </Text>
</YStack> </YStack>
@@ -205,13 +292,32 @@ export default function TasksScreen() {
{tasks.length === 0 && loading ? ( {tasks.length === 0 && loading ? (
<YStack <YStack
padding="$3" padding="$3"
borderRadius="$card" borderRadius="$bento"
backgroundColor="$surface" backgroundColor={bentoSurface.backgroundColor}
borderWidth={1} borderWidth={1}
borderColor={cardBorder} borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
style={{ boxShadow: cardShadow }}
> >
<Text fontSize="$2" color="$color" opacity={0.7}> <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> </Text>
</YStack> </YStack>
) : null} ) : null}
@@ -219,10 +325,12 @@ export default function TasksScreen() {
<YStack <YStack
key={task.id} key={task.id}
padding="$3" padding="$3"
borderRadius="$card" borderRadius="$bento"
backgroundColor="$surface" backgroundColor={bentoSurface.backgroundColor}
borderWidth={1} borderWidth={1}
borderColor={cardBorder} borderBottomWidth={3}
borderColor={bentoSurface.borderColor}
borderBottomColor={bentoSurface.borderBottomColor}
gap="$2" gap="$2"
style={{ style={{
boxShadow: cardShadow, boxShadow: cardShadow,
@@ -233,18 +341,20 @@ export default function TasksScreen() {
<Text fontSize="$4" fontWeight="$7"> <Text fontSize="$4" fontWeight="$7">
{task.title} {task.title}
</Text> </Text>
{task.emotion ? ( {task.emotion?.name ? (
<Text fontSize="$2" color="$color" opacity={0.7}> <Text fontSize="$2" color="$color" opacity={0.7}>
{emotions[task.emotion] ?? task.emotion} {task.emotion.name}
</Text> </Text>
) : null} ) : null}
</YStack> </YStack>
{typeof task.points === 'number' ? (
<XStack alignItems="center" gap="$2"> <XStack alignItems="center" gap="$2">
<Trophy size={16} color="#FDE047" /> <Trophy size={16} color="#FDE047" />
<Text fontSize="$2" fontWeight="$7"> <Text fontSize="$2" fontWeight="$7">
+{task.points ?? 0} +{task.points}
</Text> </Text>
</XStack> </XStack>
) : null}
</XStack> </XStack>
<Button <Button
size="$3" size="$3"
@@ -252,7 +362,7 @@ export default function TasksScreen() {
borderRadius="$pill" borderRadius="$pill"
borderWidth={1} borderWidth={1}
borderColor={mutedButtonBorder} borderColor={mutedButtonBorder}
onPress={() => navigate(`./${task.id}`)} onPress={() => navigate(buildEventPath(token, `/upload?taskId=${task.id}`))}
> >
<XStack alignItems="center" gap="$2"> <XStack alignItems="center" gap="$2">
<Play size={14} color={isDark ? '#F8FAFF' : '#0F172A'} /> <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> { export async function fetchEventStats(eventToken: string): Promise<EventStats> {
const cached = statsCache.get(eventToken); 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`, `/api/v1/events/${encodeURIComponent(eventToken)}/stats`,
{ {
headers: { headers: {
@@ -24,6 +30,8 @@ export async function fetchEventStats(eventToken: string): Promise<EventStats> {
const stats: EventStats = { const stats: EventStats = {
onlineGuests: response.data?.online_guests ?? 0, onlineGuests: response.data?.online_guests ?? 0,
tasksSolved: response.data?.tasks_solved ?? 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, latestPhotoAt: response.data?.latest_photo_at ?? null,
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -227,6 +227,7 @@ function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | nu
const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? ''; const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? '';
const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? ''; const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? '';
const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? ''; const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? '';
const welcomeMessage = raw.welcome_message ?? null;
return { return {
primaryColor: primary ?? '', primaryColor: primary ?? '',
@@ -260,6 +261,7 @@ function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | nu
}, },
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto', mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
useDefaultBranding: raw.use_default_branding ?? undefined, useDefaultBranding: raw.use_default_branding ?? undefined,
welcomeMessage,
}; };
} }

View File

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

View File

@@ -5,6 +5,7 @@ export interface EventBranding {
backgroundColor: string; backgroundColor: string;
fontFamily: string | null; fontFamily: string | null;
logoUrl: string | null; logoUrl: string | null;
welcomeMessage?: string | null;
// Extended branding shape // Extended branding shape
useDefaultBranding?: boolean; 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);
}
}