From f2473c6f6d64f67cd8ad4928cc9d618a8cac6b5e Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 15 Dec 2025 19:05:27 +0100 Subject: [PATCH] enhancements of the homepage in the guest pwa --- package-lock.json | 92 ++++ package.json | 6 +- public/patterns/pattern-hearts-dark.svg | 1 + public/patterns/pattern-hearts-red.svg | 1 + public/patterns/pattern-triangles-dark.svg | 1 + public/patterns/rays-sunburst.svg | 1 + resources/css/app.css | 4 +- .../js/guest/components/EmotionPicker.tsx | 87 ++-- .../js/guest/components/GalleryPreview.tsx | 105 +++-- resources/js/guest/components/Header.tsx | 29 +- .../js/guest/context/EventBrandingContext.tsx | 12 +- resources/js/guest/i18n/messages.ts | 26 ++ resources/js/guest/pages/GalleryPage.tsx | 18 +- resources/js/guest/pages/HomePage.tsx | 392 +++++++++++++----- 14 files changed, 568 insertions(+), 207 deletions(-) create mode 100644 public/patterns/pattern-hearts-dark.svg create mode 100644 public/patterns/pattern-hearts-red.svg create mode 100644 public/patterns/pattern-triangles-dark.svg create mode 100644 public/patterns/rays-sunburst.svg diff --git a/package-lock.json b/package-lock.json index 3fec733..79846bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@react-spring/web": "^10.0.3", "@stripe/stripe-js": "^8.5.3", "@tailwindcss/vite": "^4.1.17", "@tamagui/button": "~1.139.2", @@ -42,6 +43,7 @@ "@tanstack/react-query": "^5.90.12", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@use-gesture/react": "^10.3.1", "@vitejs/plugin-react": "^4.7.0", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", @@ -4810,6 +4812,78 @@ } } }, + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@react-stately/flags": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", @@ -7667,6 +7741,24 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", diff --git a/package.json b/package.json index 8a44409..716c5d9 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@react-spring/web": "^10.0.3", "@stripe/stripe-js": "^8.5.3", "@tailwindcss/vite": "^4.1.17", "@tamagui/button": "~1.139.2", @@ -79,6 +80,7 @@ "@tanstack/react-query": "^5.90.12", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@use-gesture/react": "^10.3.1", "@vitejs/plugin-react": "^4.7.0", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", @@ -95,9 +97,9 @@ "i18next-http-backend": "^3.0.2", "laravel-vite-plugin": "^2.0.1", "lucide-react": "^0.475.0", - "pdf-lib": "^1.17.1", - "react-colorful": "^5.6.1", + "pdf-lib": "^1.17.1", "react": "^19.2.1", + "react-colorful": "^5.6.1", "react-dom": "^19.2.1", "react-hot-toast": "^2.6.0", "react-i18next": "^16.4.1", diff --git a/public/patterns/pattern-hearts-dark.svg b/public/patterns/pattern-hearts-dark.svg new file mode 100644 index 0000000..1648ecd --- /dev/null +++ b/public/patterns/pattern-hearts-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/patterns/pattern-hearts-red.svg b/public/patterns/pattern-hearts-red.svg new file mode 100644 index 0000000..4bb749e --- /dev/null +++ b/public/patterns/pattern-hearts-red.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/patterns/pattern-triangles-dark.svg b/public/patterns/pattern-triangles-dark.svg new file mode 100644 index 0000000..b4de36f --- /dev/null +++ b/public/patterns/pattern-triangles-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/patterns/rays-sunburst.svg b/public/patterns/rays-sunburst.svg new file mode 100644 index 0000000..42ba825 --- /dev/null +++ b/public/patterns/rays-sunburst.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 4f457bc..26315f8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -11,7 +11,7 @@ @theme { --font-sans: - 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + 'Montserrat', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --font-display: 'Playfair Display', serif; --font-serif: 'Lora', serif; --font-sans-marketing: 'Montserrat', sans-serif; @@ -123,7 +123,7 @@ --guest-link: #007aff; --guest-font-family: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; --guest-body-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; - --guest-heading-font: 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --guest-heading-font: 'Playfair Display', 'Montserrat', 'Inter', 'Helvetica Neue', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; --guest-serif-font: 'Lora', serif; } diff --git a/resources/js/guest/components/EmotionPicker.tsx b/resources/js/guest/components/EmotionPicker.tsx index 847778f..bae6179 100644 --- a/resources/js/guest/components/EmotionPicker.tsx +++ b/resources/js/guest/components/EmotionPicker.tsx @@ -110,51 +110,54 @@ export default function EmotionPicker({ )} -
- {emotions.map((emotion) => { - // Localize name and description if they are JSON - const localize = (value: string | object, defaultValue: string = ''): string => { - if (typeof value === 'string' && value.startsWith('{')) { - try { - const data = JSON.parse(value as string); - return data.de || data.en || defaultValue || ''; - } catch { - return value as string; +
+
+ {emotions.map((emotion) => { + // Localize name and description if they are JSON + const localize = (value: string | object, defaultValue: string = ''): string => { + if (typeof value === 'string' && value.startsWith('{')) { + try { + const data = JSON.parse(value as string); + return data.de || data.en || defaultValue || ''; + } catch { + return value as string; + } } - } - return value as string; - }; + return value as string; + }; - const localizedName = localize(emotion.name, emotion.name); - const localizedDescription = localize(emotion.description || '', ''); - return ( -
- - ); - })} + + ); + })} +
+
{/* Skip option */} diff --git a/resources/js/guest/components/GalleryPreview.tsx b/resources/js/guest/components/GalleryPreview.tsx index 1b9ad3c..ce1669a 100644 --- a/resources/js/guest/components/GalleryPreview.tsx +++ b/resources/js/guest/components/GalleryPreview.tsx @@ -6,6 +6,7 @@ import { getDeviceId } from '../lib/device'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; import { Heart } from 'lucide-react'; import { useTranslation } from '../i18n/useTranslation'; +import { useEventBranding } from '../context/EventBrandingContext'; type Props = { token: string }; @@ -27,10 +28,15 @@ type PreviewPhoto = { export default function GalleryPreview({ token }: Props) { const { locale } = useTranslation(); + const { branding } = useEventBranding(); const { photos, loading } = usePollGalleryDelta(token, locale); const [mode, setMode] = React.useState('latest'); const typedPhotos = React.useMemo(() => photos as PreviewPhoto[], [photos]); const hasPhotobooth = React.useMemo(() => typedPhotos.some((p) => p.ingest_source === 'photobooth'), [typedPhotos]); + const radius = branding.buttons?.radius ?? 12; + const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; + const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const items = React.useMemo(() => { let arr = typedPhotos.slice(); @@ -84,64 +90,82 @@ export default function GalleryPreview({ token }: Props) { ]; return ( -
-
-
-

Live-Galerie

-

Alle Uploads auf einen Blick

+ + +
+
+

Live-Galerie

+

Alle Uploads auf einen Blick

+
+ + Alle ansehen → +
- - Alle ansehen → - -
-
+
{filters.map((filter) => ( ))}
- {loading &&

Lädt…

} - {!loading && items.length === 0 && ( - - + {loading &&

Lädt…

} + {!loading && items.length === 0 && ( +
+ Noch keine Fotos. Starte mit deinem ersten Upload! - - - )} +
+ )} -
+
{items.map((p: PreviewPhoto) => ( {p.title -
-
-

{p.title || getPhotoTitle(p)}

-
- +
+
+

+ {p.title || getPhotoTitle(p)} +

+
+ {p.likes_count ?? 0}
@@ -149,12 +173,13 @@ export default function GalleryPreview({ token }: Props) { ))}
-

- Lust auf mehr?{' '} - - Zur Galerie → - -

-
+

+ Lust auf mehr?{' '} + + Zur Galerie → + +

+ + ); } diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 8d1769e..007e85b 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -172,19 +172,13 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string }, [notificationsOpen]); if (!eventToken) { - const guestName = identity?.name && identity?.hydrated ? identity.name : null; return (
{title}
- {guestName && ( - - {`${t('common.hi')} ${guestName}`} - - )}
@@ -194,20 +188,20 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string ); } - const guestName = - identity && identity.eventKey === eventToken && identity.hydrated && identity.name ? identity.name : null; + const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const headerStyle: React.CSSProperties = { background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`, color: headerTextColor, - fontFamily: branding.fontFamily ?? undefined, + fontFamily: headerFont, }; const accentColor = branding.secondaryColor; if (status === 'loading') { return ( -
+
{t('header.loading')}
@@ -225,19 +219,14 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string statsContext && statsContext.eventKey === eventToken ? statsContext : undefined; return (
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)} -
-
{event.name}
- {guestName && ( - - {`${t('common.hi')} ${guestName}`} - - )} -
+
+
{event.name}
+
{stats && ( <> diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/guest/context/EventBrandingContext.tsx index 2e08aba..30c374a 100644 --- a/resources/js/guest/context/EventBrandingContext.tsx +++ b/resources/js/guest/context/EventBrandingContext.tsx @@ -10,7 +10,7 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = { primaryColor: '#f43f5e', secondaryColor: '#fb7185', backgroundColor: '#ffffff', - fontFamily: null, + fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif', logoUrl: null, palette: { primary: '#f43f5e', @@ -19,8 +19,8 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = { surface: '#ffffff', }, typography: { - heading: null, - body: null, + heading: 'Playfair Display, "Times New Roman", serif', + body: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif', sizePreset: 'm', }, logo: { @@ -71,7 +71,7 @@ function resolveBranding(input?: EventBranding | null): EventBranding { primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor), secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor), backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor), - fontFamily: bodyFont?.trim() || null, + fontFamily: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.fontFamily, logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null, palette: { primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor), @@ -80,8 +80,8 @@ function resolveBranding(input?: EventBranding | null): EventBranding { surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor), }, typography: { - heading: headingFont?.trim() || null, - body: bodyFont?.trim() || null, + heading: headingFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.heading || null, + body: bodyFont?.trim() || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily, sizePreset, }, logo: { diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index bc3e384..b09cdcf 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -132,6 +132,19 @@ export const messages: Record = { }, home: { fallbackGuestName: 'Gast', + welcomeLine: 'Willkommen {name}!', + introRotating: { + 0: 'Hilf uns, diesen besonderen Tag mit deinen schönsten Momenten festzuhalten.', + 1: 'Fang die Stimmung des Events ein und teile sie mit allen Gästen.', + 2: 'Deine Sicht zählt: Halte Augenblicke fest, die sonst niemand bemerkt.', + 3: 'Erfülle kleine Fotoaufgaben und fülle die gemeinsame Galerie mit Leben.', + 4: 'Zeig uns, was du siehst – deine Fotos erzählen die Geschichte dieses Tages.', + 5: 'Mach aus Schnappschüssen gemeinsame Erinnerungen in einer großen Event-Galerie.', + 6: 'Diese App ist eure Fotozentrale – fotografiere, lade hoch und begeistere die anderen.', + 7: 'Sorge dafür, dass kein wichtiger Moment verloren geht – mit deinen Bildern.', + 8: 'Lass dich von Fotoaufgaben inspirieren und halte die besonderen Szenen fest.', + 9: 'Mach mit beim Fotospiel: Deine Fotos machen dieses Event unvergesslich.', + }, hero: { subtitle: 'Willkommen zur Party', title: 'Hey {name}!', @@ -776,6 +789,19 @@ export const messages: Record = { }, home: { fallbackGuestName: 'Guest', + welcomeLine: 'Welcome {name}!', + introRotating: { + 0: 'Help us capture this special day with your favourite moments.', + 1: 'Capture the mood of the event and share it with everyone.', + 2: 'Your view matters: save the moments that others might miss.', + 3: 'Complete playful photo missions and fill the shared gallery with life.', + 4: 'Show us what you see – your photos tell the story of this day.', + 5: 'Turn quick snapshots into shared memories in one big event gallery.', + 6: 'This app is your photo hub – shoot, upload, and delight the other guests.', + 7: 'Make sure no important moment gets lost – with your pictures.', + 8: 'Let photo missions inspire you and capture the scenes that really matter.', + 9: 'Join the photo game: your pictures make this event unforgettable.', + }, hero: { subtitle: 'Welcome to the party', title: 'Hey {name}!', diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index 1b2de86..37090d3 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -322,7 +322,7 @@ export default function GalleryPage() { styleOverride={{ borderRadius: radius, fontFamily: headingFont }} /> {loading &&

{t('galleryPage.loading', 'Lade…')}

} -
+
{list.map((p: GalleryPhoto) => { const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path); const createdLabel = p.created_at @@ -357,7 +357,7 @@ export default function GalleryPage() { {altText} { (e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg=='; }} @@ -425,6 +425,20 @@ export default function GalleryPage() {
); })} + {list.length === 0 && Array.from({ length: 6 }).map((_, idx) => ( +
+
+
+ +
+
+
+
+ ))}
{currentPhotoIndex !== null && list.length > 0 && ( (); @@ -25,17 +27,7 @@ export default function HomePage() { const radius = branding.buttons?.radius ?? 12; const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed'; - const [heroVisible, setHeroVisible] = React.useState(() => { - if (typeof window === 'undefined') { - return true; - } - - try { - return window.sessionStorage.getItem(heroStorageKey) !== '1'; - } catch { - return true; - } - }); + const [heroVisible, setHeroVisible] = React.useState(false); React.useEffect(() => { if (typeof window === 'undefined') { @@ -43,9 +35,11 @@ export default function HomePage() { } try { - setHeroVisible(window.sessionStorage.getItem(heroStorageKey) !== '1'); + const stored = window.sessionStorage.getItem(heroStorageKey); + // standardmäßig versteckt, nur sichtbar falls explizit gesetzt (kann später wieder aktiviert werden) + setHeroVisible(stored === 'show'); } catch { - setHeroVisible(true); + setHeroVisible(false); } }, [heroStorageKey]); @@ -67,20 +61,37 @@ export default function HomePage() { const accentColor = branding.primaryColor; const secondaryAccent = branding.secondaryColor; - const [missionPreview, setMissionPreview] = React.useState(null); + const [missionDeck, setMissionDeck] = React.useState([]); const [missionLoading, setMissionLoading] = React.useState(false); const missionPoolRef = React.useRef([]); - const shuffleMissionPreview = React.useCallback(() => { + const drawRandom = React.useCallback((excludeIds: Set) => { + const pool = missionPoolRef.current.filter((item) => !excludeIds.has(item.id)); + if (!pool.length) return null; + return pool[Math.floor(Math.random() * pool.length)]; + }, []); + + const resetDeck = React.useCallback(() => { const pool = missionPoolRef.current; if (!pool.length) { - setMissionPreview(null); + setMissionDeck([]); return; } - const choice = pool[Math.floor(Math.random() * pool.length)]; - setMissionPreview(choice); + const shuffled = [...pool].sort(() => Math.random() - 0.5); + setMissionDeck(shuffled.slice(0, 4)); }, []); + const advanceDeck = React.useCallback(() => { + setMissionDeck((prev) => { + if (!prev.length) return prev; + const [, ...rest] = prev; + const exclude = new Set(rest.map((r) => r.id)); + const nextCandidate = drawRandom(exclude); + const replenished = nextCandidate ? [...rest, nextCandidate] : rest; + return replenished; + }); + }, [drawRandom]); + React.useEffect(() => { if (!token) return; let cancelled = false; @@ -108,16 +119,16 @@ export default function HomePage() { duration: typeof task.duration === 'number' ? task.duration : 3, emotion: task.emotion ?? null, })); - shuffleMissionPreview(); + resetDeck(); } else { missionPoolRef.current = []; - setMissionPreview(null); + setMissionDeck([]); } } catch (err) { if (!cancelled) { console.warn('Mission preview failed', err); missionPoolRef.current = []; - setMissionPreview(null); + setMissionDeck([]); } } finally { if (!cancelled) { @@ -129,14 +140,35 @@ export default function HomePage() { return () => { cancelled = true; }; - }, [shuffleMissionPreview, token, locale]); + }, [resetDeck, token, locale]); if (!token) { return null; } + const introArray: string[] = []; + for (let i = 0; i < 12; i += 1) { + const candidate = t(`home.introRotating.${i}`, ''); + if (candidate) { + introArray.push(candidate); + } + } + const introMessage = + introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : ''; + return ( -
+
+
+

+ {t('home.welcomeLine').replace('{name}', displayName)} +

+ {introMessage && ( +

+ {introMessage} +

+ )} +
+ {heroVisible && ( )} -
-
-
-

Starte dein Fotospiel

-

Wähle, wie du den nächsten Moment einfängst.

-
-
- -
+
+
- +
@@ -254,58 +280,233 @@ function MissionActionCard({ token, mission, loading, - onShuffle, + onAdvance, + stack, }: { token: string; mission: MissionPreview | null; loading: boolean; - onShuffle: () => void; + onAdvance: () => void; + stack: MissionPreview[]; }) { + const { branding } = useEventBranding(); + const radius = branding.buttons?.radius ?? 12; + const primary = branding.buttons?.primary ?? branding.primaryColor; + const secondary = branding.buttons?.secondary ?? branding.secondaryColor; + const buttonStyle = branding.buttons?.style ?? 'filled'; + const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; + const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; + const textColor = '#1f2937'; + const subTextColor = '#334155'; + const swipeThreshold = 120; + const stackLayers = stack.slice(1, 4); + + const cardStyle: React.CSSProperties = { + borderRadius: `${radius + 8}px`, + backgroundColor: '#fcf7ef', + backgroundImage: `linear-gradient(0deg, ${primary}33, ${primary}22), url(/patterns/rays-sunburst.svg)`, + backgroundBlendMode: 'multiply, normal', + backgroundSize: '330% 330%', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + filter: 'contrast(1.12) saturate(1.1)', + border: `1px solid ${primary}26`, + boxShadow: `0 12px 28px ${primary}22, 0 2px 6px ${primary}1f`, + fontFamily: bodyFont, + position: 'relative', + overflow: 'hidden', + }; + + const [{ x, y, rotateZ, rotateY, rotateX, scale, opacity }, api] = useSpring(() => ({ + x: 0, + y: 0, + rotateZ: 0, + rotateY: 0, + rotateX: 0, + scale: 1, + opacity: 1, + config: { tension: 320, friction: 26 }, + })); + + React.useEffect(() => { + api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false }); + }, [mission?.id, api]); + + const bind = useGesture( + { + onDrag: ({ active, movement: [mx, my], velocity: [vx], direction: [dx], cancel }) => { + if (active && Math.abs(mx) > swipeThreshold) { + cancel?.(); + api.start({ + x: dx > 0 ? 520 : -520, + y: my, + rotateZ: dx > 0 ? 12 : -12, + rotateY: dx > 0 ? 18 : -18, + rotateX: -my / 10, + opacity: 0, + scale: 1, + immediate: false, + onRest: () => { + onAdvance(); + api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, opacity: 1, scale: 1, immediate: false }); + }, + }); + return; + } + + api.start({ + x: mx, + y: my, + rotateZ: mx / 18, + rotateY: mx / 28, + rotateX: -my / 36, + scale: active ? 1.02 : 1, + opacity: 1, + immediate: false, + }); + }, + onDragEnd: () => { + api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false }); + }, + }, + { + drag: { + filterTaps: true, + bounds: { left: -200, right: 200, top: -120, bottom: 120 }, + rubberband: true, + }, + } + ); + return ( - - -
- -
-
- Mission starten - - Wir haben bereits eine Aufgabe für dich vorbereitet. Tippe, um direkt loszulegen. - -
-
- - {mission ? ( - <> -

{mission.title}

- {mission.description && ( -

{mission.description}

- )} -
- {mission.duration ?? 3} Min - {mission.emotion?.name && {mission.emotion.name}} -
- - ) : ( -

- Ziehe deine erste Mission im Aufgaben-Tab oder lade deine Stimmung hoch. -

- )} -
- - -
+
+
+
+
+
+ +
+
+

+ Fotoaufgabe +

+

+ Wir haben schon etwas für dich vorbereitet. +

+
+
+
+ + {mission ? ( +
+
+

+ {mission.title} +

+ {mission.description && ( +

+ {mission.description} +

+ )} +
+
+ ) : ( +

+ Ziehe deine erste Mission im Aufgaben-Tab oder wähle eine Stimmung. +

+ )} + +
+ + +
+
+ ); @@ -314,13 +515,13 @@ function MissionActionCard({ function EmotionActionCard() { return ( - - Foto nach Gefühlslage + + Wähle eine Stimmung und erhalte eine passende Aufgabe - Wähle deine Stimmung, wir schlagen dir passende Missionen vor. + Tippe deinen Mood, wir picken die nächste Mission für dich. - + @@ -342,30 +543,35 @@ function UploadActionCard({ }) { return ( - +
-
- +
+
-

Direkt hochladen

-

Kamera öffnen oder ein Foto aus deiner Galerie wählen.

+

Direkt hochladen

+

Kamera öffnen oder ein Foto aus deiner Galerie wählen.

+

Offline möglich – wir laden später hoch.

);