Update guest v2 home and tasks experience
This commit is contained in:
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'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();
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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', () => ({
|
||||||
|
|||||||
@@ -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!');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +181,51 @@ 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[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const stored = window.sessionStorage.getItem(welcomeStorageKey);
|
||||||
|
setWelcomeVisible(stored !== '1');
|
||||||
|
} catch {
|
||||||
|
setWelcomeVisible(true);
|
||||||
|
}
|
||||||
|
}, [welcomeStorageKey]);
|
||||||
|
|
||||||
const rings = [
|
const rings = [
|
||||||
tasksEnabled
|
{
|
||||||
? {
|
label: t('home.actions.items.upload.label', 'Upload photo'),
|
||||||
label: t('home.actions.items.tasks.label', 'Draw a task card'),
|
icon: <Camera size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||||
icon: <Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
path: '/upload',
|
||||||
path: '/tasks',
|
},
|
||||||
}
|
|
||||||
: {
|
|
||||||
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'),
|
label: t('homeV2.rings.newUploads', 'New uploads'),
|
||||||
icon: <ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
icon: <ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||||
@@ -218,10 +240,10 @@ export default function HomeScreen() {
|
|||||||
label: t('navigation.achievements', 'Achievements'),
|
label: t('navigation.achievements', 'Achievements'),
|
||||||
icon: <Trophy size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
icon: <Trophy size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />,
|
||||||
path: '/achievements',
|
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 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) => ({
|
||||||
@@ -510,25 +534,66 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<YStack gap="$4">
|
<YStack gap="$4">
|
||||||
<YStack
|
{welcomeVisible && welcomeMessage.trim() ? (
|
||||||
gap="$3"
|
<YStack
|
||||||
animation="slow"
|
gap="$3"
|
||||||
animateOnly={['transform', 'opacity']}
|
animation="slow"
|
||||||
opacity={revealStage >= 1 ? 1 : 0}
|
animateOnly={['transform', 'opacity']}
|
||||||
y={revealStage >= 1 ? 0 : 12}
|
opacity={revealStage >= 1 ? 1 : 0}
|
||||||
>
|
y={revealStage >= 1 ? 0 : 12}
|
||||||
<XStack gap="$2" flexWrap="nowrap">
|
>
|
||||||
{rings.map((ring) => (
|
<YStack
|
||||||
<YStack key={ring.label} flex={1} minWidth={0}>
|
position="relative"
|
||||||
<ActionTile label={ring.label} icon={ring.icon} onPress={goTo(ring.path)} isDark={isDark} />
|
padding="$4"
|
||||||
</YStack>
|
borderRadius="$bentoLg"
|
||||||
))}
|
backgroundColor={bentoSurface.backgroundColor}
|
||||||
</XStack>
|
borderWidth={1}
|
||||||
</YStack>
|
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 ? (
|
{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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
if (highlight && tasks.some((task) => task.id === highlight.id)) return;
|
||||||
|
setHighlight(tasks[0] ?? null);
|
||||||
|
}, [highlight, tasks]);
|
||||||
|
|
||||||
const emotionMap: Record<string, string> = {};
|
const handleStartTask = React.useCallback(() => {
|
||||||
for (const emotion of emotionList) {
|
if (!highlight) return;
|
||||||
const record = emotion as Record<string, unknown>;
|
navigate(buildEventPath(token, `/upload?taskId=${highlight.id}`));
|
||||||
const slug = typeof record.slug === 'string' ? record.slug : '';
|
}, [highlight, navigate, token]);
|
||||||
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
|
|
||||||
if (slug) {
|
|
||||||
emotionMap[slug] = title || slug;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setEmotions(emotionMap);
|
|
||||||
|
|
||||||
const mapped = taskList
|
const handleShuffle = React.useCallback(() => {
|
||||||
.map((task) => {
|
if (!tasks.length) return;
|
||||||
const record = task as Record<string, unknown>;
|
const candidates = tasks.filter((task) => task.id !== highlight?.id);
|
||||||
const id = Number(record.id ?? 0);
|
const nextList = candidates.length ? candidates : tasks;
|
||||||
const title = typeof record.title === 'string' ? record.title : typeof record.name === 'string' ? record.name : '';
|
const next = nextList[Math.floor(Math.random() * nextList.length)];
|
||||||
if (!id || !title) return null;
|
setHighlight(next);
|
||||||
return {
|
setHasSwiped(true);
|
||||||
id,
|
}, [highlight?.id, tasks]);
|
||||||
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 handleViewSimilar = React.useCallback(() => {
|
||||||
setHighlight(mapped[0] ?? null);
|
if (!highlight) return;
|
||||||
})
|
navigate(buildEventPath(token, `/gallery?task=${highlight.id}`));
|
||||||
.catch((err) => {
|
}, [highlight, navigate, token]);
|
||||||
console.error('Failed to load tasks', err);
|
|
||||||
if (active) {
|
|
||||||
setError(t('tasks.error', 'Tasks could not be loaded.'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (active) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
const handleOpenPhoto = React.useCallback(
|
||||||
active = false;
|
(photoId: number) => {
|
||||||
};
|
if (!highlight) return;
|
||||||
}, [token, locale, t]);
|
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,68 +211,78 @@ 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">
|
</Text>
|
||||||
Prompt quest
|
|
||||||
</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>
|
||||||
<XStack alignItems="center" justifyContent="space-between">
|
|
||||||
<Text fontSize="$2" fontWeight="$7">
|
<TaskHeroCard
|
||||||
{t('tasks.page.progressLabel', 'Quest progress')}
|
task={highlight}
|
||||||
</Text>
|
loading={loading}
|
||||||
<Text fontSize="$2" fontWeight="$7">
|
error={error}
|
||||||
{highlight ? `${progressPercent}%` : '--'}
|
hasSwiped={hasSwiped}
|
||||||
</Text>
|
onSwiped={() => setHasSwiped(true)}
|
||||||
</XStack>
|
onStart={handleStartTask}
|
||||||
<YStack backgroundColor="$muted" borderRadius="$pill" height={10} overflow="hidden">
|
onShuffle={handleShuffle}
|
||||||
<YStack backgroundColor="$primary" width={highlight ? `${progressPercent}%` : '20%'} height={10} />
|
onViewSimilar={handleViewSimilar}
|
||||||
</YStack>
|
onRetry={loadTasks}
|
||||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
onOpenPhoto={handleOpenPhoto}
|
||||||
{t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')}
|
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>
|
||||||
|
<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>
|
</YStack>
|
||||||
<Button
|
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||||
size="$4"
|
{t('tasks.page.progressValue', { count: completedCount, total: tasks.length }, '{count}/{total} tasks completed')}
|
||||||
backgroundColor="$primary"
|
</Text>
|
||||||
borderRadius="$pill"
|
|
||||||
disabled={!highlight || loading}
|
|
||||||
onPress={() => {
|
|
||||||
if (highlight) navigate(`./${highlight.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? t('tasks.loading', 'Loading tasks...') : t('tasks.start', 'Start quest')}
|
|
||||||
</Button>
|
|
||||||
</YStack>
|
</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>
|
||||||
<XStack alignItems="center" gap="$2">
|
{typeof task.points === 'number' ? (
|
||||||
<Trophy size={16} color="#FDE047" />
|
<XStack alignItems="center" gap="$2">
|
||||||
<Text fontSize="$2" fontWeight="$7">
|
<Trophy size={16} color="#FDE047" />
|
||||||
+{task.points ?? 0}
|
<Text fontSize="$2" fontWeight="$7">
|
||||||
</Text>
|
+{task.points}
|
||||||
</XStack>
|
</Text>
|
||||||
|
</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'} />
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../context/EventStatsContext', () => ({
|
|||||||
useEventStats: () => ({
|
useEventStats: () => ({
|
||||||
latestPhotoAt: null,
|
latestPhotoAt: null,
|
||||||
onlineGuests: 2,
|
onlineGuests: 2,
|
||||||
|
tasksSolved: 0,
|
||||||
|
guestCount: 2,
|
||||||
|
likesCount: 0,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ vi.mock('../../context/EventStatsContext', () => ({
|
|||||||
useEventStats: () => ({
|
useEventStats: () => ({
|
||||||
latestPhotoAt: null,
|
latestPhotoAt: null,
|
||||||
onlineGuests: 0,
|
onlineGuests: 0,
|
||||||
|
tasksSolved: 0,
|
||||||
|
guestCount: 0,
|
||||||
|
likesCount: 0,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ vi.mock('../../context/EventStatsContext', () => ({
|
|||||||
useEventStats: () => ({
|
useEventStats: () => ({
|
||||||
latestPhotoAt: null,
|
latestPhotoAt: null,
|
||||||
onlineGuests: 2,
|
onlineGuests: 2,
|
||||||
|
tasksSolved: 0,
|
||||||
|
guestCount: 2,
|
||||||
|
likesCount: 0,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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