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 (
-
handleEmotionSelect(emotion)}
- className="group flex min-w-[180px] flex-col gap-2 rounded-2xl border border-white/40 bg-white/80 px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-pink-200 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900/70"
- >
-
-
- {emotion.emoji}
-
-
-
{localizedName}
- {localizedDescription && (
-
{localizedDescription}
- )}
+ const localizedName = localize(emotion.name, emotion.name);
+ const localizedDescription = localize(emotion.description || '', '');
+ return (
+
handleEmotionSelect(emotion)}
+ className="group flex flex-col gap-2 rounded-2xl border border-muted/40 bg-white/80 px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-foreground/10 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900/70"
+ >
+
+
+ {emotion.emoji}
+
+
+
{localizedName}
+ {localizedDescription && (
+
{localizedDescription}
+ )}
+
+
-
-
-
- );
- })}
+
+ );
+ })}
+
+
{/* 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) => (
setMode(filter.value)}
- className={`rounded-full border px-4 py-1 transition ${
- mode === filter.value
- ? 'border-pink-500 bg-pink-500 text-white shadow'
- : 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200'
- }`}
+ style={{
+ borderRadius: radius,
+ border: mode === filter.value ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
+ background: mode === filter.value ? branding.primaryColor : 'var(--guest-surface)',
+ color: mode === filter.value ? '#ffffff' : 'var(--foreground)',
+ boxShadow: mode === filter.value ? `0 8px 18px ${branding.primaryColor}33` : 'none',
+ }}
+ className="px-4 py-1 transition"
>
{filter.label}
))}
- {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 || 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() {
{
(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.
-
- )}
-
-
- Mission starten
-
-
+
+
+ {stackLayers.map((item, index) => {
+ const depth = index + 1;
+ const scaleDown = 1 - depth * 0.03;
+ const translateY = depth * 12;
+ const fade = Math.max(0.25, 0.55 - depth * 0.08);
+ return (
+
+ );
+ })}
+
-
- Andere Mission
-
-
+
+
+
+
+
+
+
+
+
+ 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.
+
+ )}
+
+
+
+
+ Aufgabe starten
+
+
+
+
+ Andere Aufgabe
+
+
+
+
);
@@ -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.
Foto hochladen
+
Offline möglich – wir laden später hoch.
);