From 298a8375b64f998ef2f43a1a53f29782e2afa417 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 3 Feb 2026 15:18:44 +0100 Subject: [PATCH] Update guest v2 branding and theming --- .../Controllers/Api/EventPublicController.php | 43 ++++ resources/js/admin/mobile/BrandingPage.tsx | 221 +++++++++++++----- .../mobile/__tests__/BrandingPage.test.tsx | 4 + resources/js/guest-v2/App.tsx | 2 + .../js/guest-v2/__tests__/EventLogo.test.tsx | 59 +++++ .../__tests__/PhotoLightboxScreen.test.tsx | 6 +- .../guest-v2/__tests__/ScreensCopy.test.tsx | 8 + .../guest-v2/__tests__/ShareScreen.test.tsx | 63 +++++ .../guest-v2/components/AmbientBackground.tsx | 5 +- resources/js/guest-v2/components/AppShell.tsx | 6 +- .../js/guest-v2/components/BottomDock.tsx | 5 +- .../js/guest-v2/components/CompassHub.tsx | 5 +- .../js/guest-v2/components/EventLogo.tsx | 167 +++++++++++++ .../js/guest-v2/components/FabActionRing.tsx | 5 +- .../js/guest-v2/components/FabActionSheet.tsx | 5 +- .../components/FloatingActionButton.tsx | 5 +- .../components/GuestAnalyticsNudge.tsx | 5 +- .../guest-v2/components/NotificationSheet.tsx | 8 +- .../js/guest-v2/components/PhotoFrameTile.tsx | 5 +- .../guest-v2/components/SettingsContent.tsx | 6 +- .../js/guest-v2/components/SettingsSheet.tsx | 8 +- .../js/guest-v2/components/ShareSheet.tsx | 186 +++++++++++++++ .../js/guest-v2/components/SurfaceCard.tsx | 5 +- .../js/guest-v2/components/TaskHeroCard.tsx | 5 +- .../js/guest-v2/components/ToastHost.tsx | 155 ++++++++++++ resources/js/guest-v2/components/TopBar.tsx | 47 +++- resources/js/guest-v2/lib/brandingTheme.tsx | 8 +- resources/js/guest-v2/lib/guestTheme.ts | 28 +++ resources/js/guest-v2/lib/toast.ts | 18 ++ .../guest-v2/screens/AchievementsScreen.tsx | 5 +- .../js/guest-v2/screens/GalleryScreen.tsx | 5 +- .../js/guest-v2/screens/HelpArticleScreen.tsx | 29 ++- .../js/guest-v2/screens/HelpCenterScreen.tsx | 25 +- resources/js/guest-v2/screens/HomeScreen.tsx | 5 +- .../js/guest-v2/screens/LandingScreen.tsx | 13 +- resources/js/guest-v2/screens/LegalScreen.tsx | 15 +- .../js/guest-v2/screens/LiveShowScreen.tsx | 10 +- .../guest-v2/screens/PhotoLightboxScreen.tsx | 124 +++++++--- .../guest-v2/screens/ProfileSetupScreen.tsx | 17 +- .../guest-v2/screens/PublicGalleryScreen.tsx | 38 ++- resources/js/guest-v2/screens/ShareScreen.tsx | 74 +++++- .../js/guest-v2/screens/SharedPhotoScreen.tsx | 50 +++- .../js/guest-v2/screens/SlideshowScreen.tsx | 10 +- .../js/guest-v2/screens/TaskDetailScreen.tsx | 5 +- resources/js/guest-v2/screens/TasksScreen.tsx | 5 +- .../js/guest-v2/screens/UploadQueueScreen.tsx | 5 +- .../js/guest-v2/screens/UploadScreen.tsx | 8 +- resources/js/guest-v2/services/qrApi.ts | 19 ++ .../js/guest/context/EventBrandingContext.tsx | 8 +- .../__tests__/EventBrandingContext.test.tsx | 23 ++ resources/js/guest/i18n/messages.ts | 8 + .../services/__tests__/photosApi.test.ts | 33 +++ resources/js/guest/services/galleryApi.ts | 15 +- resources/js/guest/services/photosApi.ts | 2 +- routes/api.php | 1 + tests/Feature/Api/Event/EventQrCodeTest.php | 35 +++ tests/Feature/Api/PhotoShareLinkTest.php | 13 ++ 57 files changed, 1416 insertions(+), 277 deletions(-) create mode 100644 resources/js/guest-v2/__tests__/EventLogo.test.tsx create mode 100644 resources/js/guest-v2/__tests__/ShareScreen.test.tsx create mode 100644 resources/js/guest-v2/components/EventLogo.tsx create mode 100644 resources/js/guest-v2/components/ShareSheet.tsx create mode 100644 resources/js/guest-v2/components/ToastHost.tsx create mode 100644 resources/js/guest-v2/lib/guestTheme.ts create mode 100644 resources/js/guest-v2/lib/toast.ts create mode 100644 resources/js/guest-v2/services/qrApi.ts create mode 100644 resources/js/guest/services/__tests__/photosApi.test.ts create mode 100644 tests/Feature/Api/Event/EventQrCodeTest.php diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index adec057..cce1de3 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -41,6 +41,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; +use SimpleSoftwareIO\QrCode\Facades\QrCode; use Symfony\Component\HttpFoundation\Response; class EventPublicController extends BaseController @@ -1734,6 +1735,7 @@ class EventPublicController extends BaseController 'name' => $event->name, 'city' => $event->city, ] : null, + 'branding' => $event ? $this->resolveBrandingPayload($event) : null, ])->header('Cache-Control', 'no-store'); } @@ -1980,6 +1982,47 @@ class EventPublicController extends BaseController ])->header('Cache-Control', 'no-store'); } + public function qr(Request $request, string $token): JsonResponse + { + $result = $this->resolvePublishedEvent($request, $token, ['id']); + + if ($result instanceof JsonResponse) { + return $result; + } + + [, $joinToken] = $result; + + $joinTokenValue = $joinToken->token ?? $token; + $qrCodeUrl = $joinTokenValue ? url('/e/'.$joinTokenValue) : null; + $qrCodeDataUrl = null; + + if ($qrCodeUrl) { + $requestedSize = (int) $request->query('size', 360); + $size = max(120, min($requestedSize, 640)); + + try { + $png = QrCode::format('png') + ->size($size) + ->margin(1) + ->errorCorrection('M') + ->generate($qrCodeUrl); + + $pngBinary = (string) $png; + + if ($pngBinary !== '') { + $qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary); + } + } catch (\Throwable $exception) { + report($exception); + } + } + + return response()->json([ + 'url' => $qrCodeUrl, + 'qr_code_data_url' => $qrCodeDataUrl, + ])->header('Cache-Control', 'no-store'); + } + public function package(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 22c153e..849e85d 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { Theme } from '@tamagui/core'; import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save, Droplets, Lock } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; @@ -240,6 +241,27 @@ export default function MobileBrandingPage() { const previewLogoUrl = previewForm.logoMode === 'upload' ? previewForm.logoDataUrl : ''; const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : ''; const previewInitials = getInitials(previewTitle); + const previewThemeName = previewForm.mode === 'dark' ? 'guestNight' : 'guestLight'; + const previewIsDark = previewThemeName === 'guestNight'; + const previewVariables = { + '--guest-primary': previewForm.primary, + '--guest-secondary': previewForm.accent, + '--guest-background': previewBackground, + '--guest-surface': previewSurface, + '--guest-font-scale': String(previewScale), + '--guest-body-font': previewBodyFont, + '--guest-heading-font': previewHeadingFont, + '--guest-button-radius': `${previewForm.buttonRadius}px`, + '--guest-radius': `${previewForm.buttonRadius}px`, + '--guest-link': previewForm.linkColor, + } as React.CSSProperties; + const previewAmbient = previewIsDark + ? 'radial-gradient(circle at 18% 12%, rgba(255, 110, 110, 0.22), transparent 46%), radial-gradient(circle at 82% 18%, rgba(78, 205, 196, 0.18), transparent 44%), linear-gradient(180deg, rgba(6, 9, 20, 0.96), rgba(10, 14, 28, 1))' + : 'radial-gradient(circle at 18% 12%, color-mix(in oklab, var(--guest-primary, #FF6B6B) 24%, white), transparent 50%), radial-gradient(circle at 82% 18%, color-mix(in oklab, var(--guest-secondary, #4ECDC4) 20%, white), transparent 48%), linear-gradient(180deg, color-mix(in oklab, var(--guest-background, #FFF5F5) 96%, white), color-mix(in oklab, var(--guest-background, #FFF5F5) 72%, white))'; + const previewSurfaceShadow = previewIsDark + ? '0 18px 32px rgba(2, 6, 23, 0.55)' + : '0 16px 28px rgba(15, 23, 42, 0.15)'; + const previewIconSurface = previewIsDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.08)'; const watermarkAllowed = isWatermarkAllowed(event ?? null); const watermarkRemovalAllowed = isWatermarkRemovalAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null); @@ -616,79 +638,158 @@ export default function MobileBrandingPage() { {t('events.branding.previewTitle', 'Guest App Preview')} - - + + - - - + + - {previewLogoUrl ? ( - {t('events.branding.logoAlt', - ) : ( - - {previewLogoValue || previewInitials} - - )} - - - - {previewTitle} + {previewLogoUrl ? ( + {t('events.branding.logoAlt', + ) : ( + + {previewLogoValue || previewInitials} + + )} + + + + {previewTitle} + + + {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} + + + + + + + + + + + + + {t('events.branding.previewTitleShort', 'Dein Event-Hub')} - {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} + {t('events.branding.previewSubtitleShort', 'Gäste, Fotos, Highlights')} + + + + + + - - - - - - - - -
- {t('events.branding.previewCta', 'Fotos hochladen')} -
+ + + + + {t('events.branding.previewStat', 'Online Guests')} + + + 148 + + +
+ {t('events.branding.previewCta', 'Fotos hochladen')} +
+
+
+ + + + + + + + + + + +
-
+
{!brandingAllowed ? ( diff --git a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx index c1131ea..9eea65d 100644 --- a/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/BrandingPage.test.tsx @@ -99,6 +99,10 @@ vi.mock('@tamagui/text', () => ({ SizableText: ({ children }: { children: React.ReactNode }) => {children}, })); +vi.mock('@tamagui/core', () => ({ + Theme: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + vi.mock('@tamagui/react-native-web-lite', () => ({ Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => ( + ), +})); + +vi.mock('../components/AppShell', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../context/EventDataContext', () => ({ + useEventData: () => ({ + token: 'demo-token', + event: { name: 'Demo Event', join_token: 'demo-token' }, + }), +})); + +vi.mock('../hooks/usePollStats', () => ({ + usePollStats: () => ({ stats: { onlineGuests: 12, tasksSolved: 0, latestPhotoAt: null } }), +})); + +vi.mock('../services/qrApi', () => ({ + fetchEventQrCode: () => Promise.resolve({ qr_code_data_url: 'data:image/png;base64,abc' }), +})); + +vi.mock('@/guest/i18n/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string, arg2?: Record | string, arg3?: string) => + typeof arg2 === 'string' || arg2 === undefined ? (arg2 ?? arg3 ?? key) : (arg3 ?? key), + }), +})); + +vi.mock('@/hooks/use-appearance', () => ({ + useAppearance: () => ({ resolved: 'light' }), +})); + +import ShareScreen from '../screens/ShareScreen'; + +describe('ShareScreen', () => { + it('shows guests online from stats', async () => { + render(); + + expect(await screen.findByText('12')).toBeInTheDocument(); + expect(screen.getByText('Guests joined')).toBeInTheDocument(); + expect(screen.getByText('Guests online')).toBeInTheDocument(); + expect(screen.getByAltText('Event QR code')).toBeInTheDocument(); + }); +}); diff --git a/resources/js/guest-v2/components/AmbientBackground.tsx b/resources/js/guest-v2/components/AmbientBackground.tsx index b828035..1d43f2f 100644 --- a/resources/js/guest-v2/components/AmbientBackground.tsx +++ b/resources/js/guest-v2/components/AmbientBackground.tsx @@ -1,14 +1,13 @@ import React from 'react'; import { YStack } from '@tamagui/stacks'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; type AmbientBackgroundProps = { children: React.ReactNode; }; export default function AmbientBackground({ children }: AmbientBackgroundProps) { - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); return ( { setNotificationsOpen(false); setCompassOpen(false); diff --git a/resources/js/guest-v2/components/BottomDock.tsx b/resources/js/guest-v2/components/BottomDock.tsx index 191d44c..8020ca9 100644 --- a/resources/js/guest-v2/components/BottomDock.tsx +++ b/resources/js/guest-v2/components/BottomDock.tsx @@ -7,15 +7,14 @@ import { Home, Image, Share2 } from 'lucide-react'; import { useEventData } from '../context/EventDataContext'; import { buildEventPath } from '../lib/routes'; import { useTranslation } from '@/guest/i18n/useTranslation'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; export default function BottomDock() { const location = useLocation(); const navigate = useNavigate(); const { token } = useEventData(); const { t } = useTranslation(); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const dockItems = [ { key: 'home', label: t('navigation.home', 'Home'), path: '/', icon: Home }, diff --git a/resources/js/guest-v2/components/CompassHub.tsx b/resources/js/guest-v2/components/CompassHub.tsx index 7425135..3c4172c 100644 --- a/resources/js/guest-v2/components/CompassHub.tsx +++ b/resources/js/guest-v2/components/CompassHub.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { YStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; export type CompassAction = { key: string; @@ -39,8 +39,7 @@ export default function CompassHub({ title = 'Quick jump', }: CompassHubProps) { const close = () => onOpenChange(false); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const [visible, setVisible] = React.useState(open); const [closing, setClosing] = React.useState(false); diff --git a/resources/js/guest-v2/components/EventLogo.tsx b/resources/js/guest-v2/components/EventLogo.tsx new file mode 100644 index 0000000..a5fcdbb --- /dev/null +++ b/resources/js/guest-v2/components/EventLogo.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { YStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Camera, Heart, PartyPopper, Users } from 'lucide-react'; +import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext'; +import { getContrastingTextColor } from '@/guest/lib/color'; +import type { EventBranding } from '@/guest/types/event-branding'; + +type LogoSize = 's' | 'm' | 'l'; + +type EventLogoProps = { + name: string; + icon?: string | null; + logo?: EventBranding['logo'] | null; + size?: LogoSize; +}; + +const EVENT_ICON_COMPONENTS: Record> = { + heart: Heart, + guests: Users, + party: PartyPopper, + camera: Camera, +}; + +const LOGO_SIZE_MAP: Record = { + s: { container: 32, image: 24, emoji: 16, icon: 14, text: 12 }, + m: { container: 40, image: 30, emoji: 18, icon: 18, text: 14 }, + l: { container: 48, image: 38, emoji: 22, icon: 22, text: 16 }, +}; + +function isLikelyEmoji(value: string): boolean { + if (!value) { + return false; + } + const characters = Array.from(value.trim()); + if (characters.length === 0 || characters.length > 2) { + return false; + } + return characters.some((char) => { + const codePoint = char.codePointAt(0) ?? 0; + return codePoint > 0x2600; + }); +} + +function getInitials(name: string): string { + const words = name.split(' ').filter(Boolean); + if (words.length >= 2) { + return `${words[0][0]}${words[1][0]}`.toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); +} + +export default function EventLogo({ name, icon, logo, size }: EventLogoProps) { + const brandingContext = useOptionalEventBranding(); + const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING; + const resolvedLogo = logo ?? branding.logo; + const logoMode = resolvedLogo?.mode ?? (branding.logoUrl ? 'upload' : 'emoticon'); + const logoValue = resolvedLogo?.value ?? branding.logoUrl ?? null; + const logoSize = size ?? resolvedLogo?.size ?? 'm'; + const sizes = LOGO_SIZE_MAP[logoSize]; + const accentColor = branding.secondaryColor || DEFAULT_EVENT_BRANDING.secondaryColor; + const textColor = getContrastingTextColor(accentColor, '#ffffff', '#0f172a'); + const [logoFailed, setLogoFailed] = React.useState(false); + + React.useEffect(() => { + setLogoFailed(false); + }, [logoValue]); + + if (logoMode === 'upload' && logoValue && !logoFailed) { + return ( + + {name} setLogoFailed(true)} + /> + + ); + } + + if (logoMode === 'emoticon' && logoValue && isLikelyEmoji(logoValue)) { + return ( + + + {logoValue} + + + ); + } + + if (typeof icon === 'string') { + const trimmed = icon.trim(); + if (trimmed) { + const normalized = trimmed.toLowerCase(); + const IconComponent = EVENT_ICON_COMPONENTS[normalized]; + if (IconComponent) { + return ( + + + + ); + } + + if (isLikelyEmoji(trimmed)) { + return ( + + + {trimmed} + + + ); + } + } + } + + return ( + + + {getInitials(name)} + + + ); +} diff --git a/resources/js/guest-v2/components/FabActionRing.tsx b/resources/js/guest-v2/components/FabActionRing.tsx index 46416f7..f2dfe15 100644 --- a/resources/js/guest-v2/components/FabActionRing.tsx +++ b/resources/js/guest-v2/components/FabActionRing.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { YStack, XStack } from '@tamagui/stacks'; import { Button } from '@tamagui/button'; import { SizableText as Text } from '@tamagui/text'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; type FabAction = { key: string; @@ -25,8 +25,7 @@ const positions = [ ]; export default function FabActionRing({ open, onOpenChange, actions }: FabActionRingProps) { - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const borderColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(15, 23, 42, 0.12)'; const surfaceColor = isDark ? 'rgba(12, 16, 32, 0.92)' : 'rgba(255, 255, 255, 0.95)'; const textColor = isDark ? '#F8FAFF' : '#0F172A'; diff --git a/resources/js/guest-v2/components/FabActionSheet.tsx b/resources/js/guest-v2/components/FabActionSheet.tsx index 3a92446..171435c 100644 --- a/resources/js/guest-v2/components/FabActionSheet.tsx +++ b/resources/js/guest-v2/components/FabActionSheet.tsx @@ -3,7 +3,7 @@ import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Sheet } from '@tamagui/sheet'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; export type FabAction = { key: string; @@ -21,8 +21,7 @@ type FabActionSheetProps = { }; export default function FabActionSheet({ open, onOpenChange, title, actions }: FabActionSheetProps) { - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); if (!open) { return null; diff --git a/resources/js/guest-v2/components/FloatingActionButton.tsx b/resources/js/guest-v2/components/FloatingActionButton.tsx index ebbb913..63985a4 100644 --- a/resources/js/guest-v2/components/FloatingActionButton.tsx +++ b/resources/js/guest-v2/components/FloatingActionButton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button } from '@tamagui/button'; import { Flower } from 'lucide-react'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; type FloatingActionButtonProps = { onPress: () => void; @@ -10,8 +10,7 @@ type FloatingActionButtonProps = { export default function FloatingActionButton({ onPress, onLongPress }: FloatingActionButtonProps) { const longPressTriggered = React.useRef(false); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); return ( + + + + + + + + + + {url ? ( + + {url} + + ) : null} + + + + ); +} diff --git a/resources/js/guest-v2/components/SurfaceCard.tsx b/resources/js/guest-v2/components/SurfaceCard.tsx index a52835e..e07cf49 100644 --- a/resources/js/guest-v2/components/SurfaceCard.tsx +++ b/resources/js/guest-v2/components/SurfaceCard.tsx @@ -1,15 +1,14 @@ import React from 'react'; import { YStack } from '@tamagui/stacks'; import type { YStackProps } from '@tamagui/stacks'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; type SurfaceCardProps = YStackProps & { glow?: boolean; }; export default function SurfaceCard({ children, glow = false, ...props }: SurfaceCardProps) { - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const borderColor = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; const boxShadow = isDark ? glow diff --git a/resources/js/guest-v2/components/TaskHeroCard.tsx b/resources/js/guest-v2/components/TaskHeroCard.tsx index 30f8502..5a792dc 100644 --- a/resources/js/guest-v2/components/TaskHeroCard.tsx +++ b/resources/js/guest-v2/components/TaskHeroCard.tsx @@ -6,7 +6,7 @@ import { Camera, CheckCircle2, Heart, RefreshCw, Sparkles, Timer as TimerIcon } import PhotoFrameTile from './PhotoFrameTile'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '@/guest/lib/emotionTheme'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; import { getBentoSurfaceTokens } from '../lib/bento'; type TaskHeroEmotion = EmotionIdentity & { emoji?: string | null }; @@ -65,8 +65,7 @@ export default function TaskHeroCard({ const heroCardRef = React.useRef(null); const theme = getEmotionTheme(task?.emotion ?? null); const emotionIcon = getEmotionIcon(task?.emotion ?? null); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const bentoSurface = getBentoSurfaceTokens(isDark); React.useEffect(() => { diff --git a/resources/js/guest-v2/components/ToastHost.tsx b/resources/js/guest-v2/components/ToastHost.tsx new file mode 100644 index 0000000..6f0f0d2 --- /dev/null +++ b/resources/js/guest-v2/components/ToastHost.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; +import { Button } from '@tamagui/button'; +import { useGuestThemeVariant } from '../lib/guestTheme'; + +type ToastAction = { label: string; onClick: () => void }; +type ToastType = 'success' | 'error' | 'info'; + +export type GuestToast = { + id: number; + text: string; + type?: ToastType; + action?: ToastAction; + durationMs?: number; +}; + +type ToastPayload = Omit; + +const DEFAULT_DURATION = 3000; + +export default function ToastHost() { + const [list, setList] = React.useState([]); + const timeouts = React.useRef>(new Map()); + const { isDark } = useGuestThemeVariant(); + + const dismiss = React.useCallback((id: number) => { + setList((arr) => arr.filter((t) => t.id !== id)); + const timeout = timeouts.current.get(id); + if (timeout) { + window.clearTimeout(timeout); + timeouts.current.delete(id); + } + }, []); + + const push = React.useCallback( + (toast: ToastPayload) => { + const id = Date.now() + Math.floor(Math.random() * 1000); + const durationMs = toast.durationMs ?? DEFAULT_DURATION; + setList((arr) => [...arr, { id, ...toast, durationMs }]); + if (durationMs > 0 && typeof window !== 'undefined') { + const timeout = window.setTimeout(() => dismiss(id), durationMs); + timeouts.current.set(id, timeout); + } + }, + [dismiss] + ); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + const onEvt = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.text) { + push(detail); + } + }; + window.addEventListener('guest-toast', onEvt as EventListener); + return () => window.removeEventListener('guest-toast', onEvt as EventListener); + }, [push]); + + React.useEffect(() => { + return () => { + for (const timeout of timeouts.current.values()) { + window.clearTimeout(timeout); + } + timeouts.current.clear(); + }; + }, []); + + const resolveTone = (type?: ToastType) => { + if (type === 'error') { + return { + border: isDark ? 'rgba(248, 113, 113, 0.5)' : 'rgba(248, 113, 113, 0.4)', + background: isDark ? 'rgba(127, 29, 29, 0.7)' : 'rgba(254, 226, 226, 0.95)', + text: isDark ? '#FEE2E2' : '#991B1B', + }; + } + if (type === 'info') { + return { + border: isDark ? 'rgba(59, 130, 246, 0.45)' : 'rgba(59, 130, 246, 0.35)', + background: isDark ? 'rgba(30, 64, 175, 0.65)' : 'rgba(219, 234, 254, 0.95)', + text: isDark ? '#DBEAFE' : '#1D4ED8', + }; + } + return { + border: isDark ? 'rgba(34, 197, 94, 0.45)' : 'rgba(34, 197, 94, 0.35)', + background: isDark ? 'rgba(22, 101, 52, 0.7)' : 'rgba(220, 252, 231, 0.95)', + text: isDark ? '#DCFCE7' : '#166534', + }; + }; + + if (!list.length) { + return null; + } + + return ( + + + {list.map((toast) => { + const tone = resolveTone(toast.type); + return ( + + + {toast.text} + + {toast.action ? ( + + ) : null} + + ); + })} + + + ); +} diff --git a/resources/js/guest-v2/components/TopBar.tsx b/resources/js/guest-v2/components/TopBar.tsx index b0e3c42..2218379 100644 --- a/resources/js/guest-v2/components/TopBar.tsx +++ b/resources/js/guest-v2/components/TopBar.tsx @@ -3,10 +3,13 @@ import { XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Bell, Settings } from 'lucide-react'; -import { useAppearance } from '@/hooks/use-appearance'; +import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '@/guest/context/EventBrandingContext'; +import EventLogo from './EventLogo'; +import { useGuestThemeVariant } from '../lib/guestTheme'; type TopBarProps = { eventName: string; + eventIcon?: string | null; onProfilePress?: () => void; onNotificationsPress?: () => void; notificationCount?: number; @@ -14,18 +17,28 @@ type TopBarProps = { export default function TopBar({ eventName, + eventIcon, onProfilePress, onNotificationsPress, notificationCount = 0, }: TopBarProps) { - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); + const brandingContext = useOptionalEventBranding(); + const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING; + const logoPosition = branding.logo?.position ?? 'left'; const [animationKey, setAnimationKey] = React.useState(0); React.useEffect(() => { setAnimationKey((prev) => prev + 1); }, [eventName]); + const identityDirection = logoPosition === 'right' + ? 'row-reverse' + : logoPosition === 'center' + ? 'column' + : 'row'; + const identityAlign = logoPosition === 'center' ? 'center' : 'flex-start'; + return ( - - {eventName} - + + + {eventName} + + @@ -584,6 +628,22 @@ export default function PhotoLightboxScreen() { )}
+ { + if (!open) { + closeShareSheet(); + } + }} + photoId={selected?.id} + eventName={event?.name ?? null} + url={shareSheet.url} + loading={shareSheet.loading} + onShareNative={() => shareNative(shareSheet.url)} + onShareWhatsApp={() => shareWhatsApp(shareSheet.url)} + onShareMessages={() => shareMessages(shareSheet.url)} + onCopyLink={() => copyLink(shareSheet.url)} + /> ); } diff --git a/resources/js/guest-v2/screens/ProfileSetupScreen.tsx b/resources/js/guest-v2/screens/ProfileSetupScreen.tsx index 00415f7..eb82c9c 100644 --- a/resources/js/guest-v2/screens/ProfileSetupScreen.tsx +++ b/resources/js/guest-v2/screens/ProfileSetupScreen.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { YStack } from '@tamagui/stacks'; +import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Input } from '@tamagui/input'; @@ -8,7 +8,8 @@ import { Card } from '@tamagui/card'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useEventData } from '../context/EventDataContext'; import { useGuestIdentity } from '../context/GuestIdentityContext'; -import { useAppearance } from '@/hooks/use-appearance'; +import EventLogo from '../components/EventLogo'; +import { useGuestThemeVariant } from '../lib/guestTheme'; export default function ProfileSetupScreen() { const { token } = useParams<{ token: string }>(); @@ -16,8 +17,7 @@ export default function ProfileSetupScreen() { const { t } = useTranslation(); const { event, status } = useEventData(); const identity = useGuestIdentity(); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const primaryText = isDark ? '#F8FAFF' : '#0F172A'; const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'; const cardBackground = isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(255, 255, 255, 0.9)'; @@ -67,9 +67,12 @@ export default function ProfileSetupScreen() { - - {event.name} - + + + + {event.name} + + {t('profileSetup.card.description')} diff --git a/resources/js/guest-v2/screens/PublicGalleryScreen.tsx b/resources/js/guest-v2/screens/PublicGalleryScreen.tsx index d4fdbcd..65d8d99 100644 --- a/resources/js/guest-v2/screens/PublicGalleryScreen.tsx +++ b/resources/js/guest-v2/screens/PublicGalleryScreen.tsx @@ -3,17 +3,18 @@ import { useParams } from 'react-router-dom'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; -import { Image as ImageIcon, Download, Share2 } from 'lucide-react'; +import { Download, Share2 } from 'lucide-react'; import { Sheet } from '@tamagui/sheet'; import StandaloneShell from '../components/StandaloneShell'; import SurfaceCard from '../components/SurfaceCard'; +import EventLogo from '../components/EventLogo'; import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '@/guest/services/galleryApi'; import { createPhotoShareLink } from '@/guest/services/photosApi'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { EventBrandingProvider } from '@/guest/context/EventBrandingContext'; import { mapEventBranding } from '../lib/eventBranding'; import { BrandingTheme } from '../lib/brandingTheme'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; type GalleryState = { meta: GalleryMetaResponse | null; @@ -40,9 +41,6 @@ const PAGE_SIZE = 30; export default function PublicGalleryScreen() { const { token } = useParams<{ token: string }>(); const { t, locale } = useTranslation(); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; - const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; const [state, setState] = React.useState(INITIAL_STATE); const [selected, setSelected] = React.useState(null); const [shareLoading, setShareLoading] = React.useState(false); @@ -50,16 +48,10 @@ export default function PublicGalleryScreen() { const branding = React.useMemo(() => { if (!state.meta) return null; - const raw = state.meta.branding ?? null; - return mapEventBranding({ - primary_color: raw.primary_color, - secondary_color: raw.secondary_color, - background_color: raw.background_color, - surface_color: raw.surface_color ?? raw.background_color, - palette: raw.palette ?? undefined, - mode: raw.mode ?? 'auto', - } as any); + return mapEventBranding(state.meta.branding ?? null); }, [state.meta]); + const { isDark } = useGuestThemeVariant(branding); + const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; const loadInitial = React.useCallback(async () => { if (!token) return; @@ -117,15 +109,17 @@ export default function PublicGalleryScreen() { const content = ( - - - - {t('galleryPublic.title')} - + + + + + {t('galleryPublic.title')} + + + {state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} + + - - {state.meta?.event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} - {state.loading ? ( diff --git a/resources/js/guest-v2/screens/ShareScreen.tsx b/resources/js/guest-v2/screens/ShareScreen.tsx index 567fe86..175d1fd 100644 --- a/resources/js/guest-v2/screens/ShareScreen.tsx +++ b/resources/js/guest-v2/screens/ShareScreen.tsx @@ -6,21 +6,22 @@ import { Share2, QrCode, Link, Users } from 'lucide-react'; import AppShell from '../components/AppShell'; import { useEventData } from '../context/EventDataContext'; import { buildEventShareLink } from '../services/eventLink'; -import { useAppearance } from '@/hooks/use-appearance'; +import { usePollStats } from '../hooks/usePollStats'; +import { fetchEventQrCode } from '../services/qrApi'; +import { useGuestThemeVariant } from '../lib/guestTheme'; import { useTranslation } from '@/guest/i18n/useTranslation'; export default function ShareScreen() { const { event, token } = useEventData(); const { t } = useTranslation(); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)'; const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'failed'>('idle'); + const { stats } = usePollStats(token ?? null); + const [qrCodeDataUrl, setQrCodeDataUrl] = React.useState(''); + const [qrLoading, setQrLoading] = React.useState(false); const shareUrl = buildEventShareLink(event, token); - const qrUrl = shareUrl - ? `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(shareUrl)}` - : ''; const handleCopy = React.useCallback(async () => { if (!shareUrl) { @@ -52,6 +53,44 @@ export default function ShareScreen() { } }, [event?.name, handleCopy, shareUrl]); + React.useEffect(() => { + if (!token) { + setQrCodeDataUrl(''); + setQrLoading(false); + return; + } + + let active = true; + setQrLoading(true); + + fetchEventQrCode(token, 240) + .then((payload) => { + if (!active) { + return; + } + setQrCodeDataUrl(payload.qr_code_data_url ?? ''); + }) + .catch((error) => { + if (!active) { + return; + } + console.error('Failed to load QR code', error); + setQrCodeDataUrl(''); + }) + .finally(() => { + if (active) { + setQrLoading(false); + } + }); + + return () => { + active = false; + }; + }, [token]); + + const guestCountLabel = stats.onlineGuests.toString(); + const inviteDisabled = !shareUrl; + return ( @@ -94,14 +133,14 @@ export default function ShareScreen() { : 'radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 18%, white), transparent 60%)', }} > - {qrUrl ? ( + {qrCodeDataUrl ? ( {t('share.invite.qrAlt', ) : ( - + )} {t('share.invite.qrLabel', 'Show QR')} @@ -155,12 +194,27 @@ export default function ShareScreen() { {t('share.invite.guestsTitle', 'Guests joined')} + + + {guestCountLabel} + + + {t('home.stats.online', 'Guests online')} + + {event?.name ? t('share.invite.guestsSubtitleEvent', 'Share {event} with your guests.', { event: event.name }) : t('share.invite.guestsSubtitle', 'Share the event with your guests.')} - diff --git a/resources/js/guest-v2/screens/SharedPhotoScreen.tsx b/resources/js/guest-v2/screens/SharedPhotoScreen.tsx index bc83734..887f83f 100644 --- a/resources/js/guest-v2/screens/SharedPhotoScreen.tsx +++ b/resources/js/guest-v2/screens/SharedPhotoScreen.tsx @@ -6,9 +6,14 @@ import { Button } from '@tamagui/button'; import { AlertCircle, Download } from 'lucide-react'; import StandaloneShell from '../components/StandaloneShell'; import SurfaceCard from '../components/SurfaceCard'; +import EventLogo from '../components/EventLogo'; import { fetchPhotoShare } from '@/guest/services/photosApi'; +import type { EventBrandingPayload } from '@/guest/services/eventApi'; import { useTranslation } from '@/guest/i18n/useTranslation'; -import { useAppearance } from '@/hooks/use-appearance'; +import { EventBrandingProvider } from '@/guest/context/EventBrandingContext'; +import { mapEventBranding } from '../lib/eventBranding'; +import { BrandingTheme } from '../lib/brandingTheme'; +import { useGuestThemeVariant } from '../lib/guestTheme'; interface ShareResponse { slug: string; @@ -22,19 +27,25 @@ interface ShareResponse { image_urls: { full: string; thumbnail: string }; }; event?: { id: number; name?: string | null } | null; + branding?: EventBrandingPayload | null; } export default function SharedPhotoScreen() { const { slug } = useParams<{ slug: string }>(); const { t } = useTranslation(); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; - const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; const [state, setState] = React.useState<{ loading: boolean; error: string | null; data: ShareResponse | null }>({ loading: true, error: null, data: null, }); + const branding = React.useMemo(() => { + if (!state.data?.branding) { + return null; + } + return mapEventBranding(state.data.branding); + }, [state.data]); + const { isDark } = useGuestThemeVariant(branding); + const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; React.useEffect(() => { let active = true; @@ -93,15 +104,20 @@ export default function SharedPhotoScreen() { const { data } = state; const chips = buildChips(data, t); - return ( + const content = ( - - {t('share.title', 'Geteiltes Foto')} - - - {data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} - + + + + + {t('share.title', 'Geteiltes Foto')} + + + {data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} + + + {data.photo.title ? ( {data.photo.title} @@ -151,6 +167,18 @@ export default function SharedPhotoScreen() { ); + + if (!branding) { + return content; + } + + return ( + + + {content} + + + ); } function buildChips( diff --git a/resources/js/guest-v2/screens/SlideshowScreen.tsx b/resources/js/guest-v2/screens/SlideshowScreen.tsx index 846967c..35d20e4 100644 --- a/resources/js/guest-v2/screens/SlideshowScreen.tsx +++ b/resources/js/guest-v2/screens/SlideshowScreen.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { ChevronLeft, ChevronRight, Pause, Play, Maximize2, Minimize2 } from 'lucide-react'; import { useEventData } from '../context/EventDataContext'; +import EventLogo from '../components/EventLogo'; import { fetchGallery, type GalleryPhoto } from '../services/photosApi'; import { useTranslation } from '@/guest/i18n/useTranslation'; @@ -91,9 +92,12 @@ export default function SlideshowScreen() { return (
- - {event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} - +
+ + + {event?.name ?? t('galleryPage.hero.eventFallback', 'Event')} + +
{t('galleryPage.title', 'Gallery')} diff --git a/resources/js/guest-v2/screens/TaskDetailScreen.tsx b/resources/js/guest-v2/screens/TaskDetailScreen.tsx index 03d0e76..b144c71 100644 --- a/resources/js/guest-v2/screens/TaskDetailScreen.tsx +++ b/resources/js/guest-v2/screens/TaskDetailScreen.tsx @@ -10,7 +10,7 @@ import { fetchTasks, type TaskItem } from '../services/tasksApi'; import { useEventData } from '../context/EventDataContext'; import { buildEventPath } from '../lib/routes'; import { useTranslation } from '@/guest/i18n/useTranslation'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; function getTaskValue(task: TaskItem, key: string): string | undefined { const value = task?.[key as keyof TaskItem]; @@ -39,8 +39,7 @@ export default function TaskDetailScreen() { const { taskId } = useParams<{ taskId: string }>(); const { t, locale } = useTranslation(); const navigate = useNavigate(); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; const [task, setTask] = React.useState(null); const [loading, setLoading] = React.useState(true); diff --git a/resources/js/guest-v2/screens/TasksScreen.tsx b/resources/js/guest-v2/screens/TasksScreen.tsx index 4904755..3beafad 100644 --- a/resources/js/guest-v2/screens/TasksScreen.tsx +++ b/resources/js/guest-v2/screens/TasksScreen.tsx @@ -9,7 +9,7 @@ import { useTranslation } from '@/guest/i18n/useTranslation'; import { useLocale } from '@/guest/i18n/LocaleContext'; import { fetchTasks } from '../services/tasksApi'; import { fetchEmotions } from '../services/emotionsApi'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; import { useNavigate } from 'react-router-dom'; import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; @@ -27,8 +27,7 @@ export default function TasksScreen() { const { locale } = useLocale(); const navigate = useNavigate(); const { completedCount } = useGuestTaskProgress(token ?? undefined); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; const cardShadow = isDark ? '0 16px 32px rgba(2, 6, 23, 0.35)' : '0 14px 24px rgba(15, 23, 42, 0.12)'; const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; diff --git a/resources/js/guest-v2/screens/UploadQueueScreen.tsx b/resources/js/guest-v2/screens/UploadQueueScreen.tsx index 51bd5b6..4c86c7d 100644 --- a/resources/js/guest-v2/screens/UploadQueueScreen.tsx +++ b/resources/js/guest-v2/screens/UploadQueueScreen.tsx @@ -7,7 +7,7 @@ import AppShell from '../components/AppShell'; import SurfaceCard from '../components/SurfaceCard'; import { useUploadQueue } from '../services/uploadApi'; import { useTranslation } from '@/guest/i18n/useTranslation'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; import { useLocale } from '@/guest/i18n/LocaleContext'; import { useEventData } from '../context/EventDataContext'; import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi'; @@ -20,8 +20,7 @@ export default function UploadQueueScreen() { const { token } = useEventData(); const { items, loading, retryAll, clearFinished, refresh } = useUploadQueue(); const [progress, setProgress] = React.useState({}); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; const [pending, setPending] = React.useState([]); const [pendingLoading, setPendingLoading] = React.useState(false); diff --git a/resources/js/guest-v2/screens/UploadScreen.tsx b/resources/js/guest-v2/screens/UploadScreen.tsx index 47306fa..22b3f76 100644 --- a/resources/js/guest-v2/screens/UploadScreen.tsx +++ b/resources/js/guest-v2/screens/UploadScreen.tsx @@ -7,7 +7,7 @@ import AppShell from '../components/AppShell'; import { useEventData } from '../context/EventDataContext'; import { useOptionalGuestIdentity } from '../context/GuestIdentityContext'; import { uploadPhoto, useUploadQueue } from '../services/uploadApi'; -import { useAppearance } from '@/hooks/use-appearance'; +import { useGuestThemeVariant } from '../lib/guestTheme'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from '@/guest/i18n/useTranslation'; import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress'; @@ -15,6 +15,7 @@ import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog'; import { fetchTasks, type TaskItem } from '../services/tasksApi'; import SurfaceCard from '../components/SurfaceCard'; +import { pushGuestToast } from '../lib/toast'; function getTaskValue(task: TaskItem, key: string): string | undefined { const value = task?.[key as keyof TaskItem]; @@ -46,8 +47,7 @@ export default function UploadScreen() { const [mirror, setMirror] = React.useState(true); const [previewFile, setPreviewFile] = React.useState(null); const [previewUrl, setPreviewUrl] = React.useState(null); - const { resolved } = useAppearance(); - const isDark = resolved === 'dark'; + const { isDark } = useGuestThemeVariant(); const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'; const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)'; const iconColor = isDark ? '#F8FAFF' : '#0F172A'; @@ -186,6 +186,7 @@ export default function UploadScreen() { for (const file of files) { if (!navigator.onLine) { await enqueueFile(file); + pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' }); continue; } @@ -204,6 +205,7 @@ export default function UploadScreen() { if (autoApprove) { void triggerConfetti(); } + pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' }); void loadPending(); } catch (err) { const uploadErr = err as { code?: string; meta?: Record }; diff --git a/resources/js/guest-v2/services/qrApi.ts b/resources/js/guest-v2/services/qrApi.ts new file mode 100644 index 0000000..8988be1 --- /dev/null +++ b/resources/js/guest-v2/services/qrApi.ts @@ -0,0 +1,19 @@ +import { fetchJson } from './apiClient'; + +export type EventQrCodePayload = { + url?: string | null; + qr_code_data_url?: string | null; +}; + +export async function fetchEventQrCode(eventToken: string, size = 240): Promise { + const params = new URLSearchParams(); + if (Number.isFinite(size)) { + params.set('size', String(size)); + } + const query = params.toString(); + const url = `/api/v1/events/${encodeURIComponent(eventToken)}/qr${query ? `?${query}` : ''}`; + + const response = await fetchJson(url, { noStore: true }); + + return response.data ?? { url: null, qr_code_data_url: null }; +} diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/guest/context/EventBrandingContext.tsx index 2535be5..1cd1bfa 100644 --- a/resources/js/guest/context/EventBrandingContext.tsx +++ b/resources/js/guest/context/EventBrandingContext.tsx @@ -131,6 +131,10 @@ function resolveThemeVariant( ? 'dark' : null; + if (appearanceOverride) { + return appearanceOverride; + } + if (mode === 'dark') { return 'dark'; } @@ -139,10 +143,6 @@ function resolveThemeVariant( return 'light'; } - if (appearanceOverride) { - return appearanceOverride; - } - if (backgroundPrefers) { return backgroundPrefers; } diff --git a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx index bc20522..8590203 100644 --- a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx +++ b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx @@ -70,4 +70,27 @@ describe('EventBrandingProvider', () => { unmount(); }); + + it('prefers explicit appearance over branding mode', async () => { + localStorage.setItem('theme', 'light'); + const darkBranding: EventBranding = { + ...sampleBranding, + mode: 'dark', + backgroundColor: '#0f172a', + }; + + const { unmount } = render( + + +
Guest
+
+
+ ); + + await waitFor(() => { + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + unmount(); + }); }); diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 4405a6d..fefb9a5 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -561,6 +561,10 @@ export const messages: Record = { flash: 'Blitz', upload: 'Upload', }, + toast: { + queued: 'Offline – in der Warteschlange gespeichert.', + uploaded: 'Upload abgeschlossen.', + }, queue: { summary: '{waiting} wartend, {sending} sendend', uploading: 'Upload {name} · {progress}%', @@ -1461,6 +1465,10 @@ export const messages: Record = { flash: 'Flash', upload: 'Upload', }, + toast: { + queued: 'Offline — added to upload queue.', + uploaded: 'Upload complete.', + }, queue: { summary: '{waiting} waiting, {sending} sending', uploading: 'Uploading {name} · {progress}%', diff --git a/resources/js/guest/services/__tests__/photosApi.test.ts b/resources/js/guest/services/__tests__/photosApi.test.ts new file mode 100644 index 0000000..e5ad32c --- /dev/null +++ b/resources/js/guest/services/__tests__/photosApi.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPhotoShareLink } from '../photosApi'; + +const fetchMock = vi.fn(); + +describe('photosApi', () => { + beforeEach(() => { + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + document.head.innerHTML = ''; + localStorage.setItem('device-id', 'device-123'); + }); + + it('creates a share link with CSRF headers', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ slug: 'demo', url: 'http://example.com/share/demo' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const payload = await createPhotoShareLink('token', 123); + + expect(payload.url).toBe('http://example.com/share/demo'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0]; + const headers = options?.headers as Record; + expect(headers['X-CSRF-TOKEN']).toBe('csrf-token-demo'); + expect(headers['X-XSRF-TOKEN']).toBe('csrf-token-demo'); + expect(headers['X-Device-Id']).toBe('device-123'); + }); +}); diff --git a/resources/js/guest/services/galleryApi.ts b/resources/js/guest/services/galleryApi.ts index f7009e0..134f291 100644 --- a/resources/js/guest/services/galleryApi.ts +++ b/resources/js/guest/services/galleryApi.ts @@ -1,18 +1,7 @@ import type { LocaleCode } from '../i18n/messages'; +import type { EventBrandingPayload } from './eventApi'; -export interface GalleryBranding { - primary_color: string; - secondary_color: string; - background_color: string; - surface_color?: string; - mode?: 'light' | 'dark' | 'auto'; - palette?: { - primary?: string | null; - secondary?: string | null; - background?: string | null; - surface?: string | null; - } | null; -} +export type GalleryBranding = EventBrandingPayload; export interface GalleryMetaResponse { event: { diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts index 15dca3e..d4520da 100644 --- a/resources/js/guest/services/photosApi.ts +++ b/resources/js/guest/services/photosApi.ts @@ -174,7 +174,7 @@ export async function uploadPhoto( } export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> { - const headers = getCsrfHeaders(); + const headers = buildCsrfHeaders(); const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, { method: 'POST', diff --git a/routes/api.php b/routes/api.php index 2084051..55940d4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -174,6 +174,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); + Route::get('/events/{token}/qr', [EventPublicController::class, 'qr'])->name('events.qr'); Route::get('/events/{token}/package', [EventPublicController::class, 'package'])->name('events.package'); Route::get('/events/{token}/notifications', [EventPublicController::class, 'notifications'])->name('events.notifications'); Route::post('/events/{token}/notifications/{notification}/read', [EventPublicController::class, 'markNotificationRead']) diff --git a/tests/Feature/Api/Event/EventQrCodeTest.php b/tests/Feature/Api/Event/EventQrCodeTest.php new file mode 100644 index 0000000..b0ba21e --- /dev/null +++ b/tests/Feature/Api/Event/EventQrCodeTest.php @@ -0,0 +1,35 @@ +create([ + 'status' => 'published', + ]); + + $token = app(EventJoinTokenService::class)->createToken($event); + + $response = $this->getJson("/api/v1/events/{$token->token}/qr?size=240"); + + $response->assertOk() + ->assertJsonStructure([ + 'url', + 'qr_code_data_url', + ]) + ->assertJsonPath('url', url('/e/'.$token->token)); + + $dataUrl = $response->json('qr_code_data_url'); + $this->assertIsString($dataUrl); + $this->assertStringStartsWith('data:image/png;base64,', $dataUrl); + } +} diff --git a/tests/Feature/Api/PhotoShareLinkTest.php b/tests/Feature/Api/PhotoShareLinkTest.php index 74f5119..59d31ed 100644 --- a/tests/Feature/Api/PhotoShareLinkTest.php +++ b/tests/Feature/Api/PhotoShareLinkTest.php @@ -82,5 +82,18 @@ class PhotoShareLinkTest extends TestCase $response->assertJsonPath('photo.id', $photo->id); $response->assertJsonPath('photo.image_urls.full', fn ($value) => str_contains($value, '/photo-shares/')); $response->assertJsonPath('event.id', $event->id); + $response->assertJsonStructure([ + 'branding' => [ + 'primary_color', + 'secondary_color', + 'background_color', + 'logo_mode', + 'logo_value', + 'palette', + 'typography', + 'logo', + 'buttons', + ], + ]); } }