Update guest v2 home and tasks experience
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ export const demoFixtures: DemoFixtures = {
|
||||
stats: {
|
||||
onlineGuests: 42,
|
||||
tasksSolved: 187,
|
||||
guestCount: 128,
|
||||
likesCount: 980,
|
||||
latestPhotoAt: now(),
|
||||
},
|
||||
eventPackage: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -39,6 +39,9 @@ vi.mock('../../context/EventStatsContext', () => ({
|
||||
useEventStats: () => ({
|
||||
latestPhotoAt: null,
|
||||
onlineGuests: 2,
|
||||
tasksSolved: 0,
|
||||
guestCount: 2,
|
||||
likesCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ vi.mock('../../context/EventStatsContext', () => ({
|
||||
useEventStats: () => ({
|
||||
latestPhotoAt: null,
|
||||
onlineGuests: 0,
|
||||
tasksSolved: 0,
|
||||
guestCount: 0,
|
||||
likesCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ vi.mock('../../context/EventStatsContext', () => ({
|
||||
useEventStats: () => ({
|
||||
latestPhotoAt: null,
|
||||
onlineGuests: 2,
|
||||
tasksSolved: 0,
|
||||
guestCount: 2,
|
||||
likesCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface EventBranding {
|
||||
backgroundColor: string;
|
||||
fontFamily: string | null;
|
||||
logoUrl: string | null;
|
||||
welcomeMessage?: string | null;
|
||||
|
||||
// Extended branding shape
|
||||
useDefaultBranding?: boolean;
|
||||
|
||||
47
tests/Feature/Api/Event/EventStatsTest.php
Normal file
47
tests/Feature/Api/Event/EventStatsTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user