upgrade to tamagui v2 and guest pwa overhaul

This commit is contained in:
Codex Agent
2026-02-02 13:01:20 +01:00
parent 2e78f3ab8d
commit 7c6e14ffe2
168 changed files with 47462 additions and 8914 deletions

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type AmbientBackgroundProps = {
children: React.ReactNode;
};
export default function AmbientBackground({ children }: AmbientBackgroundProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
flex={1}
position="relative"
style={{
backgroundImage: isDark
? 'radial-gradient(circle at 15% 10%, rgba(255, 79, 216, 0.2), transparent 48%), radial-gradient(circle at 90% 20%, rgba(79, 209, 255, 0.18), transparent 40%), linear-gradient(180deg, rgba(6, 10, 22, 0.96), rgba(10, 15, 31, 1))'
: 'radial-gradient(circle at 15% 10%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 28%, white), transparent 48%), radial-gradient(circle at 90% 20%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 24%, white), transparent 40%), linear-gradient(180deg, var(--guest-background, #FFF8F5), color-mix(in oklab, var(--guest-background, #FFF8F5) 85%, white))',
backgroundSize: '140% 140%, 140% 140%, 100% 100%',
animation: 'guestNightAmbientDrift 18s ease-in-out infinite',
}}
>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,236 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { Trophy, UploadCloud, Sparkles, Cast, Share2, Compass, Image, Camera, Settings, Home } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import TopBar from './TopBar';
import BottomDock from './BottomDock';
import FloatingActionButton from './FloatingActionButton';
import FabActionSheet from './FabActionSheet';
import CompassHub, { type CompassAction } from './CompassHub';
import AmbientBackground from './AmbientBackground';
import NotificationSheet from './NotificationSheet';
import SettingsSheet from './SettingsSheet';
import GuestAnalyticsNudge from './GuestAnalyticsNudge';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useAppearance } from '@/hooks/use-appearance';
type AppShellProps = {
children: React.ReactNode;
};
export default function AppShell({ children }: AppShellProps) {
const [sheetOpen, setSheetOpen] = React.useState(false);
const [compassOpen, setCompassOpen] = React.useState(false);
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [settingsOpen, setSettingsOpen] = React.useState(false);
const { tasksEnabled, event, token } = useEventData();
const notificationCenter = useOptionalNotificationCenter();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const actionIconColor = isDark ? '#F8FAFF' : '#0F172A';
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const showFab = !/\/photo\/\d+/.test(location.pathname);
const goTo = (path: string) => () => {
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
navigate(buildEventPath(token, path));
};
const openSheet = () => {
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setSheetOpen(true);
};
const openCompass = () => {
setSheetOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
setCompassOpen(true);
};
const actions = [
{
key: 'upload',
label: t('appShell.actions.upload.label', 'Upload / Take photo'),
description: t('appShell.actions.upload.description', 'Add a moment from your device or camera.'),
icon: <UploadCloud size={18} color={actionIconColor} />,
onPress: goTo('/upload'),
},
{
key: 'compass',
label: t('appShell.actions.compass.label', 'Compass hub'),
description: t('appShell.actions.compass.description', 'Quick jump to key areas.'),
icon: <Compass size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
openCompass();
},
},
tasksEnabled
? {
key: 'task',
label: t('appShell.actions.task.label', 'Start a task'),
description: t('appShell.actions.task.description', 'Pick a challenge and capture it now.'),
icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'),
}
: null,
{
key: 'live',
label: t('appShell.actions.live.label', 'Live show'),
description: t('appShell.actions.live.description', 'See the real-time highlight stream.'),
icon: <Cast size={18} color={actionIconColor} />,
onPress: () => {
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(false);
setSettingsOpen(false);
if (token) {
navigate(`/show/${encodeURIComponent(token)}`);
}
},
},
{
key: 'slideshow',
label: t('appShell.actions.slideshow.label', 'Slideshow'),
description: t('appShell.actions.slideshow.description', 'Lean back and watch the gallery roll.'),
icon: <Image size={18} color={actionIconColor} />,
onPress: goTo('/slideshow'),
},
{
key: 'share',
label: t('appShell.actions.share.label', 'Share invite'),
description: t('appShell.actions.share.description', 'Send the event link or QR code.'),
icon: <Share2 size={18} color={actionIconColor} />,
onPress: goTo('/share'),
},
tasksEnabled
? {
key: 'achievements',
label: t('appShell.actions.achievements.label', 'Achievements'),
description: t('appShell.actions.achievements.description', 'Track your photo streaks.'),
icon: <Trophy size={18} color={actionIconColor} />,
onPress: goTo('/achievements'),
}
: null,
].filter(Boolean) as Array<{
key: string;
label: string;
description: string;
icon: React.ReactNode;
onPress?: () => void;
}>;
const compassQuadrants: [CompassAction, CompassAction, CompassAction, CompassAction] = [
{
key: 'home',
label: t('navigation.home', 'Home'),
icon: <Home size={18} color={actionIconColor} />,
onPress: goTo('/'),
},
{
key: 'gallery',
label: t('navigation.gallery', 'Gallery'),
icon: <Image size={18} color={actionIconColor} />,
onPress: goTo('/gallery'),
},
tasksEnabled
? {
key: 'tasks',
label: t('navigation.tasks', 'Tasks'),
icon: <Sparkles size={18} color={actionIconColor} />,
onPress: goTo('/tasks'),
}
: {
key: 'settings',
label: t('settings.title', 'Settings'),
icon: <Settings size={18} color={actionIconColor} />,
onPress: goTo('/settings'),
},
{
key: 'share',
label: t('navigation.share', 'Share'),
icon: <Share2 size={18} color={actionIconColor} />,
onPress: goTo('/share'),
},
];
return (
<AmbientBackground>
<YStack minHeight="100vh" position="relative">
<YStack
position="fixed"
top={0}
left={0}
right={0}
zIndex={1000}
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
<TopBar
eventName={event?.name ?? t('galleryPage.hero.eventFallback', 'Event')}
onProfilePress={() => {
setNotificationsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setSettingsOpen(true);
}}
onNotificationsPress={() => {
setSettingsOpen(false);
setSheetOpen(false);
setCompassOpen(false);
setNotificationsOpen(true);
}}
notificationCount={notificationCenter?.unreadCount ?? 0}
/>
</YStack>
<YStack
flex={1}
padding="$4"
gap="$4"
position="relative"
zIndex={1}
style={{ paddingTop: '88px', paddingBottom: '128px' }}
>
{children}
</YStack>
{showFab ? <FloatingActionButton onPress={openSheet} onLongPress={openCompass} /> : null}
<BottomDock />
<FabActionSheet
open={sheetOpen}
onOpenChange={(next) => setSheetOpen(next)}
title={t('appShell.fab.title', 'Create a moment')}
actions={actions}
/>
<CompassHub
open={compassOpen}
onOpenChange={setCompassOpen}
centerAction={{
key: 'capture',
label: t('appShell.compass.capture', 'Capture'),
icon: <Camera size={18} color="white" />,
onPress: goTo('/upload'),
}}
quadrants={compassQuadrants}
/>
<NotificationSheet open={notificationsOpen} onOpenChange={setNotificationsOpen} />
<SettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
<GuestAnalyticsNudge enabled={matomoEnabled} pathname={location.pathname} />
</YStack>
</AmbientBackground>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
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';
export default function BottomDock() {
const location = useLocation();
const navigate = useNavigate();
const { token } = useEventData();
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const dockItems = [
{ key: 'home', label: t('navigation.home', 'Home'), path: '/', icon: Home },
{ key: 'gallery', label: t('navigation.gallery', 'Gallery'), path: '/gallery', icon: Image },
{ key: 'share', label: t('navigation.share', 'Share'), path: '/share', icon: Share2 },
];
const activeIconColor = isDark ? '#F8FAFF' : '#0F172A';
const inactiveIconColor = isDark ? '#94A3B8' : '#64748B';
return (
<XStack
position="fixed"
left={0}
right={0}
bottom={0}
zIndex={1000}
paddingHorizontal="$4"
paddingBottom="$3"
paddingTop="$2"
alignItems="flex-end"
justifyContent="space-between"
borderTopWidth={1}
borderColor="rgba(255, 255, 255, 0.08)"
style={{
paddingBottom: 'calc(12px + env(safe-area-inset-bottom))',
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.85)' : 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
{dockItems.map((item) => {
const targetPath = buildEventPath(token, item.path);
const active = location.pathname === targetPath || (item.path !== '/' && location.pathname.startsWith(targetPath));
const Icon = item.icon;
return (
<Button
key={item.key}
unstyled
onPress={() => navigate(targetPath)}
padding="$2"
borderRadius="$pill"
backgroundColor={active ? '$surface' : 'transparent'}
borderWidth={active ? 1 : 0}
borderColor={active ? '$borderColor' : 'transparent'}
>
<YStack alignItems="center" gap="$1">
<Icon size={18} color={active ? activeIconColor : inactiveIconColor} />
<Text fontSize="$1" color={active ? '$color' : '$color'} opacity={active ? 1 : 0.6}>
{item.label}
</Text>
</YStack>
</Button>
);
})}
</XStack>
);
}

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { Sheet } from '@tamagui/sheet';
import { YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useAppearance } from '@/hooks/use-appearance';
export type CompassAction = {
key: string;
label: string;
icon?: React.ReactNode;
onPress?: () => void;
};
type CompassHubProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
quadrants: [CompassAction, CompassAction, CompassAction, CompassAction];
centerAction: CompassAction;
title?: string;
};
const quadrantPositions: Array<{
top?: number;
right?: number;
bottom?: number;
left?: number;
}> = [
{ top: 0, left: 0 },
{ top: 0, right: 0 },
{ bottom: 0, left: 0 },
{ bottom: 0, right: 0 },
];
export default function CompassHub({
open,
onOpenChange,
quadrants,
centerAction,
title = 'Quick jump',
}: CompassHubProps) {
const close = () => onOpenChange(false);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
if (!open) {
return null;
}
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[100]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay
{...({
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.22)',
pointerEvents: 'auto',
onClick: close,
onMouseDown: close,
onTouchStart: close,
} as any)}
/>
<Sheet.Frame
{...({
width: '100%',
height: '100%',
alignSelf: 'center',
backgroundColor: 'transparent',
padding: 24,
pointerEvents: 'box-none',
} as any)}
>
<YStack
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
pointerEvents="auto"
onPress={close}
onClick={close}
onTouchStart={close}
/>
<YStack flex={1} alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color="$color">
{title}
</Text>
<YStack width={280} height={280} position="relative" className="guest-compass-flyin">
{quadrants.map((action, index) => (
<Button
key={action.key}
onPress={() => {
action.onPress?.();
close();
}}
width={120}
height={120}
borderRadius={24}
backgroundColor="$surface"
borderWidth={1}
borderColor="$borderColor"
position="absolute"
{...quadrantPositions[index]}
>
<YStack alignItems="center" gap="$2">
{action.icon}
<Text fontSize="$3" fontWeight="$7">
{action.label}
</Text>
</YStack>
</Button>
))}
<Button
onPress={() => {
centerAction.onPress?.();
close();
}}
width={90}
height={90}
borderRadius={45}
backgroundColor="$primary"
position="absolute"
top="50%"
left="50%"
style={{ transform: 'translate(-45px, -45px)' }}
>
<YStack alignItems="center" gap="$1">
{centerAction.icon}
<Text fontSize="$2" fontWeight="$7" color="white">
{centerAction.label}
</Text>
</YStack>
</Button>
</YStack>
<Text fontSize="$2" color="$color" opacity={0.6}>
Tap outside to close
</Text>
</YStack>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
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';
export type FabAction = {
key: string;
label: string;
description?: string;
icon?: React.ReactNode;
onPress?: () => void;
};
type FabActionSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
actions: FabAction[];
};
export default function FabActionSheet({ open, onOpenChange, title, actions }: FabActionSheetProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
if (!open) {
return null;
}
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[70]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)' } as any)} />
<Sheet.Frame
{...({
width: '100%',
maxWidth: 560,
alignSelf: 'center',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
backgroundColor: '$surface',
padding: 20,
shadowColor: isDark ? 'rgba(15, 23, 42, 0.25)' : 'rgba(15, 23, 42, 0.12)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: -6 },
} as any)}
style={{ marginBottom: 'calc(16px + env(safe-area-inset-bottom))' }}
>
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
<YStack gap="$3">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
{title}
</Text>
<YStack gap="$2">
{actions.map((action) => (
<Button
key={action.key}
onPress={action.onPress}
backgroundColor="$surface"
borderRadius="$card"
borderWidth={1}
borderColor="$borderColor"
padding="$3"
justifyContent="flex-start"
>
<XStack alignItems="center" gap="$3">
<YStack
width={40}
height={40}
alignItems="center"
justifyContent="center"
borderRadius={999}
backgroundColor="$accentSoft"
>
{action.icon ? action.icon : null}
</YStack>
<YStack gap="$1" flex={1}>
<Text fontSize="$4" fontWeight="$7">
{action.label}
</Text>
{action.description ? (
<Text fontSize="$2" color="$color" opacity={0.6}>
{action.description}
</Text>
) : null}
</YStack>
</XStack>
</Button>
))}
</YStack>
</YStack>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Button } from '@tamagui/button';
import { Plus } from 'lucide-react';
import { useAppearance } from '@/hooks/use-appearance';
type FloatingActionButtonProps = {
onPress: () => void;
onLongPress?: () => void;
};
export default function FloatingActionButton({ onPress, onLongPress }: FloatingActionButtonProps) {
const longPressTriggered = React.useRef(false);
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<Button
onPress={() => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
onPress();
}}
onPressIn={() => {
longPressTriggered.current = false;
}}
onLongPress={() => {
longPressTriggered.current = true;
onLongPress?.();
}}
position="fixed"
bottom={88}
right={20}
zIndex={1100}
width={56}
height={56}
borderRadius={999}
backgroundColor="$primary"
borderWidth={0}
elevation={4}
shadowColor={isDark ? 'rgba(255, 79, 216, 0.5)' : 'rgba(15, 23, 42, 0.2)'}
shadowOpacity={0.5}
shadowRadius={18}
shadowOffset={{ width: 0, height: 10 }}
style={{
boxShadow: isDark
? '0 18px 36px rgba(255, 79, 216, 0.35), 0 0 0 6px rgba(255, 79, 216, 0.15)'
: '0 16px 28px rgba(15, 23, 42, 0.18), 0 0 0 6px rgba(255, 255, 255, 0.7)',
}}
>
<Plus size={22} color="white" />
</Button>
);
}

View File

@@ -0,0 +1,267 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useConsent } from '@/contexts/consent';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '@/guest/lib/analyticsConsent';
import { useAppearance } from '@/hooks/use-appearance';
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
const SNOOZE_MS = 60 * 60 * 1000;
const ACTIVE_IDLE_LIMIT_MS = 20_000;
type PromptStorage = {
snoozedUntil?: number | null;
};
function readSnoozedUntil(): number | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(PROMPT_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PromptStorage;
return typeof parsed.snoozedUntil === 'number' ? parsed.snoozedUntil : null;
} catch {
return null;
}
}
function writeSnoozedUntil(value: number | null) {
if (typeof window === 'undefined') {
return;
}
try {
const payload: PromptStorage = { snoozedUntil: value };
window.localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// ignore storage failures
}
}
function randomInt(min: number, max: number): number {
const low = Math.ceil(min);
const high = Math.floor(max);
return Math.floor(Math.random() * (high - low + 1)) + low;
}
export default function GuestAnalyticsNudge({
enabled,
pathname,
}: {
enabled: boolean;
pathname: string;
}) {
const { t } = useTranslation();
const { decisionMade, preferences, savePreferences } = useConsent();
const analyticsConsent = Boolean(preferences?.analytics);
const [thresholdSeconds] = React.useState(() => randomInt(60, 120));
const [thresholdRoutes] = React.useState(() => randomInt(2, 3));
const [activeSeconds, setActiveSeconds] = React.useState(0);
const [routeCount, setRouteCount] = React.useState(0);
const [isOpen, setIsOpen] = React.useState(false);
const [snoozedUntil, setSnoozedUntil] = React.useState<number | null>(() => readSnoozedUntil());
const lastPathRef = React.useRef(pathname);
const lastActivityAtRef = React.useRef(Date.now());
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const isUpload = isUploadPath(pathname);
React.useEffect(() => {
const previousPath = lastPathRef.current;
const currentPath = pathname;
lastPathRef.current = currentPath;
if (previousPath === currentPath) {
return;
}
if (isUploadPath(previousPath) || isUploadPath(currentPath)) {
return;
}
setRouteCount((count) => count + 1);
}, [pathname]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleActivity = () => {
lastActivityAtRef.current = Date.now();
};
const events: Array<keyof WindowEventMap> = [
'pointerdown',
'pointermove',
'keydown',
'scroll',
'touchstart',
];
events.forEach((event) => window.addEventListener(event, handleActivity, { passive: true }));
return () => {
events.forEach((event) => window.removeEventListener(event, handleActivity));
};
}, []);
React.useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibility = () => {
visibleRef.current = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const interval = window.setInterval(() => {
const now = Date.now();
if (!visibleRef.current) {
return;
}
if (isUploadPath(lastPathRef.current)) {
return;
}
if (now - lastActivityAtRef.current > ACTIVE_IDLE_LIMIT_MS) {
return;
}
setActiveSeconds((seconds) => seconds + 1);
}, 1000);
return () => window.clearInterval(interval);
}, []);
React.useEffect(() => {
if (!enabled || analyticsConsent || decisionMade) {
setIsOpen(false);
return;
}
const shouldOpen = shouldShowAnalyticsNudge({
decisionMade,
analyticsConsent,
snoozedUntil,
now: Date.now(),
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
});
if (shouldOpen) {
setIsOpen(true);
}
}, [
enabled,
analyticsConsent,
decisionMade,
snoozedUntil,
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
]);
React.useEffect(() => {
if (isUpload) {
setIsOpen(false);
}
}, [isUpload]);
if (!enabled || decisionMade || analyticsConsent || !isOpen || isUpload) {
return null;
}
const handleSnooze = () => {
const until = Date.now() + SNOOZE_MS;
setSnoozedUntil(until);
writeSnoozedUntil(until);
setIsOpen(false);
};
const handleAllow = () => {
savePreferences({ analytics: true });
writeSnoozedUntil(null);
setIsOpen(false);
};
return (
<YStack
position="fixed"
left={0}
right={0}
zIndex={1400}
pointerEvents="none"
paddingHorizontal="$4"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
<YStack
pointerEvents="auto"
marginHorizontal="auto"
maxWidth={560}
borderRadius="$6"
padding="$4"
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.96)' : 'rgba(255, 255, 255, 0.96)'}
style={{ backdropFilter: 'blur(16px)' }}
>
<XStack flexWrap="wrap" gap="$3" alignItems="center" justifyContent="space-between">
<YStack gap="$1" flexShrink={1} minWidth={220}>
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.title')}
</Text>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('consent.analytics.body')}
</Text>
</YStack>
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={handleSnooze}
>
<Text fontSize="$2" fontWeight="$6" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.later')}
</Text>
</Button>
<Button size="$2" borderRadius="$pill" backgroundColor="$primary" onPress={handleAllow}>
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('consent.analytics.allow')}
</Text>
</Button>
</XStack>
</XStack>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,200 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ScrollView } from '@tamagui/scroll-view';
import { X } from 'lucide-react';
import { useOptionalNotificationCenter } from '@/guest/context/NotificationCenterContext';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useAppearance } from '@/hooks/use-appearance';
type NotificationSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export default function NotificationSheet({ open, onOpenChange }: NotificationSheetProps) {
const { t } = useTranslation();
const center = useOptionalNotificationCenter();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const notifications = center?.notifications ?? [];
const unreadCount = center?.unreadCount ?? 0;
const uploadCount = (center?.queueCount ?? 0) + (center?.pendingCount ?? 0);
return (
<>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
left={0}
zIndex={1200}
pointerEvents={open ? 'auto' : 'none'}
style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)',
opacity: open ? 1 : 0,
transition: 'opacity 240ms ease',
}}
onPress={() => onOpenChange(false)}
onClick={() => onOpenChange(false)}
onMouseDown={() => onOpenChange(false)}
onTouchStart={() => onOpenChange(false)}
/>
<YStack
position="fixed"
left={0}
right={0}
bottom={0}
zIndex={1300}
padding="$4"
backgroundColor={isDark ? '#0B101E' : '#FFFFFF'}
borderTopLeftRadius="$6"
borderTopRightRadius="$6"
pointerEvents={open ? 'auto' : 'none'}
style={{
transform: open ? 'translateY(0)' : 'translateY(100%)',
opacity: open ? 1 : 0,
transition: 'transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity 220ms ease',
maxHeight: '82vh',
paddingBottom: 'calc(16px + env(safe-area-inset-bottom))',
}}
>
<YStack
width={52}
height={5}
borderRadius={999}
marginBottom="$3"
alignSelf="center"
style={{ backgroundColor: isDark ? 'rgba(148, 163, 184, 0.6)' : '#CBD5E1' }}
/>
<XStack alignItems="center" justifyContent="space-between" marginBottom="$3">
<YStack gap="$1">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('header.notifications.title', 'Updates')}
</Text>
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{unreadCount > 0
? t('header.notifications.unread', { count: unreadCount }, '{count} neu')
: t('header.notifications.allRead', 'Alles gelesen')}
</Text>
</YStack>
<Button
size="$3"
circular
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
onPress={() => onOpenChange(false)}
aria-label="Close notifications"
>
<X size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<ScrollView flex={1} showsVerticalScrollIndicator={false}>
<YStack gap="$4" paddingBottom="$2">
{center ? (
<XStack gap="$3" flexWrap="wrap">
<InfoBadge label={t('header.notifications.tabUploads', 'Uploads')} value={uploadCount} />
<InfoBadge label={t('header.notifications.tabUnread', 'Nachrichten')} value={unreadCount} />
</XStack>
) : null}
{center?.loading ? (
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('common.actions.loading', 'Loading...')}
</Text>
) : notifications.length === 0 ? (
<YStack gap="$1">
<Text color={isDark ? '#F8FAFF' : '#0F172A'} fontSize="$5" fontWeight="$7">
{t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')}
</Text>
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')}
</Text>
</YStack>
) : (
<YStack gap="$3">
{notifications.map((item) => (
<YStack
key={item.id}
padding="$3"
borderRadius="$4"
backgroundColor={
item.status === 'new'
? isDark
? 'rgba(148, 163, 184, 0.18)'
: 'rgba(15, 23, 42, 0.06)'
: isDark
? 'rgba(15, 23, 42, 0.7)'
: 'rgba(255, 255, 255, 0.8)'
}
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
gap="$2"
>
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{item.title}
</Text>
{item.body ? (
<Text color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{item.body}
</Text>
) : null}
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
backgroundColor="$primary"
color="#FFFFFF"
onPress={() => center?.markAsRead(item.id)}
>
{t('header.notifications.markRead', 'Als gelesen markieren')}
</Button>
<Button
size="$2"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
onPress={() => center?.dismiss(item.id)}
>
{t('header.notifications.dismiss', 'Ausblenden')}
</Button>
</XStack>
</YStack>
))}
</YStack>
)}
</YStack>
</ScrollView>
</YStack>
</>
);
}
function InfoBadge({ label, value }: { label: string; value: number }) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
padding="$3"
borderRadius="$4"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.7)' : 'rgba(255, 255, 255, 0.8)'}
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
gap="$1"
>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{label}
</Text>
<Text fontSize="$5" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{value}
</Text>
</YStack>
);
}

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type PhotoFrameTileProps = {
height: number;
borderRadius?: number | string;
children?: React.ReactNode;
shimmer?: boolean;
shimmerDelayMs?: number;
};
export default function PhotoFrameTile({
height,
borderRadius = '$tile',
children,
shimmer = false,
shimmerDelayMs = 0,
}: PhotoFrameTileProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<YStack
height={height}
borderRadius={borderRadius}
padding={6}
backgroundColor={isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(15, 23, 42, 0.04)'}
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)'}
style={{
boxShadow: isDark ? '0 18px 32px rgba(2, 6, 23, 0.4)' : '0 16px 28px rgba(15, 23, 42, 0.12)',
}}
>
<YStack
flex={1}
borderRadius={borderRadius}
backgroundColor="$muted"
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.1)'}
overflow="hidden"
position="relative"
style={{
boxShadow: isDark
? 'inset 0 0 0 1px rgba(255, 255, 255, 0.06)'
: 'inset 0 0 0 1px rgba(15, 23, 42, 0.04)',
}}
>
{shimmer ? (
<YStack
position="absolute"
top={-40}
bottom={-40}
left="-60%"
width="60%"
backgroundColor="transparent"
style={{
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0))',
animation: 'guestNightShimmer 4.6s ease-in-out infinite',
animationDelay: `${shimmerDelayMs}ms`,
}}
/>
) : null}
<YStack position="relative" zIndex={1} flex={1}>
{children}
</YStack>
</YStack>
</YStack>
);
}

View File

@@ -0,0 +1,362 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Input } from '@tamagui/input';
import { Card } from '@tamagui/card';
import { Switch } from '@tamagui/switch';
import { Check, Moon, RotateCcw, Sun, Languages, FileText, LifeBuoy } from 'lucide-react';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
import { useHapticsPreference } from '@/guest/hooks/useHapticsPreference';
import { triggerHaptic } from '@/guest/lib/haptics';
import { useConsent } from '@/contexts/consent';
import { useAppearance } from '@/hooks/use-appearance';
import { useEventData } from '../context/EventDataContext';
import { buildEventPath } from '../lib/routes';
const legalLinks = [
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
{ slug: 'datenschutz', labelKey: 'settings.legal.section.privacy', fallback: 'Datenschutz' },
{ slug: 'agb', labelKey: 'settings.legal.section.terms', fallback: 'AGB' },
] as const;
type SettingsContentProps = {
onNavigate?: () => void;
showHeader?: boolean;
onOpenLegal?: (slug: (typeof legalLinks)[number]['slug'], labelKey: (typeof legalLinks)[number]['labelKey']) => void;
};
export default function SettingsContent({ onNavigate, showHeader = true, onOpenLegal }: SettingsContentProps) {
const { t } = useTranslation();
const locale = useLocale();
const identity = useOptionalGuestIdentity();
const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference();
const { preferences, savePreferences } = useConsent();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const { appearance, updateAppearance } = useAppearance();
const { token } = useEventData();
const isDark = appearance === 'dark';
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.82)';
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)';
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [status, setStatus] = React.useState<'idle' | 'saved'>('idle');
const helpPath = token ? buildEventPath(token, '/help') : '/help';
const supportsInlineLegal = Boolean(onOpenLegal);
React.useEffect(() => {
if (identity?.hydrated) {
setNameDraft(identity.name ?? '');
setStatus('idle');
}
}, [identity?.hydrated, identity?.name]);
const canSaveName = Boolean(
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
);
const handleSaveName = React.useCallback(() => {
if (!identity || !canSaveName) {
return;
}
identity.setName(nameDraft);
setStatus('saved');
window.setTimeout(() => setStatus('idle'), 2000);
}, [identity, nameDraft, canSaveName]);
const handleResetName = React.useCallback(() => {
if (!identity) {
return;
}
identity.clearName();
setNameDraft('');
setStatus('idle');
}, [identity]);
return (
<YStack gap="$4">
{showHeader ? (
<YStack gap="$2">
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={primaryText}>
{t('settings.title', 'Settings')}
</Text>
<Text color={mutedText}>{t('settings.subtitle', 'Make this app yours.')}</Text>
</YStack>
) : null}
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<XStack gap="$2" alignItems="center">
<Languages size={16} color={primaryText} />
<XStack gap="$2">
{locale.availableLocales.map((option) => (
<Button
key={option.code}
size="$3"
circular
onPress={() => locale.setLocale(option.code)}
backgroundColor={option.code === locale.locale ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t(`settings.language.option.${option.code}`, option.label ?? option.code.toUpperCase())}
>
<Text fontSize="$2" color={option.code === locale.locale ? '#FFFFFF' : primaryText}>
{option.flag ?? option.code.toUpperCase()}
</Text>
</Button>
))}
</XStack>
</XStack>
<Button
size="$3"
circular
onPress={() => updateAppearance(isDark ? 'light' : 'dark')}
backgroundColor={isDark ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.appearance.darkLabel', 'Dark mode')}
>
{isDark ? <Moon size={16} color="#FFFFFF" /> : <Sun size={16} color={primaryText} />}
</Button>
</XStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.name.title', 'Your name')}
</Text>
<XStack gap="$2" alignItems="center">
<Input
flex={1}
value={nameDraft}
onChangeText={setNameDraft}
placeholder={t('settings.name.placeholder', t('profileSetup.form.placeholder'))}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.05)'}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
color={primaryText}
/>
<Button
size="$3"
circular
onPress={handleSaveName}
disabled={!canSaveName}
backgroundColor={canSaveName ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.name.save', 'Save name')}
>
<Check size={16} color={canSaveName ? '#FFFFFF' : primaryText} />
</Button>
<Button
size="$3"
circular
onPress={handleResetName}
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
aria-label={t('settings.name.reset', 'Reset')}
>
<RotateCcw size={16} color={primaryText} />
</Button>
</XStack>
{status === 'saved' ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.name.saved', 'Saved')}
</Text>
) : null}
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" color={primaryText}>
{t('settings.haptics.label', 'Haptic feedback')}
</Text>
<Switch
size="$3"
checked={hapticsEnabled}
disabled={!hapticsSupported}
onCheckedChange={(checked) => {
setHapticsEnabled(checked);
if (checked) {
triggerHaptic('selection');
}
}}
aria-label="haptics-toggle"
backgroundColor={hapticsEnabled ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Switch.Thumb backgroundColor={hapticsEnabled ? '#FFFFFF' : primaryText} borderRadius={999} />
</Switch>
</XStack>
{!hapticsSupported ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.haptics.unsupported', 'Haptics are not available on this device.')}
</Text>
) : null}
</Card>
{matomoEnabled ? (
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$3" color={primaryText}>
{t('settings.analytics.label', 'Share anonymous analytics')}
</Text>
<Switch
size="$3"
checked={Boolean(preferences?.analytics)}
onCheckedChange={(checked) => savePreferences({ analytics: checked })}
backgroundColor={preferences?.analytics ? '$primary' : mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Switch.Thumb backgroundColor={preferences?.analytics ? '#FFFFFF' : primaryText} borderRadius={999} />
</Switch>
</XStack>
<Text fontSize="$2" color={mutedText} marginTop="$2">
{t('settings.analytics.note', 'You can change this anytime.')}
</Text>
</Card>
) : null}
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$2">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.legal.title', 'Legal')}
</Text>
<YStack gap="$2">
{legalLinks.map((page) => {
const label = t(page.labelKey, page.fallback);
if (supportsInlineLegal) {
return (
<Button
key={page.slug}
onPress={() => onOpenLegal?.(page.slug, page.labelKey)}
justifyContent="space-between"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<XStack alignItems="center" gap="$2">
<FileText size={16} color={primaryText} />
<Text color={primaryText}>{label}</Text>
</XStack>
</Button>
);
}
return (
<Button
key={page.slug}
asChild
justifyContent="space-between"
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
<Link to={`/legal/${page.slug}`} onClick={onNavigate}>
{label}
</Link>
</Button>
);
})}
</YStack>
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.cache.title', 'Offline cache')}
</Text>
<ClearCacheButton />
<Text fontSize="$2" color={mutedText}>
{t('settings.cache.note', 'This only affects this browser. Pending uploads may be lost.')}
</Text>
</YStack>
</Card>
<Card padding="$3" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={primaryText}>
{t('settings.help.title', 'Help Center')}
</Text>
<Button asChild backgroundColor={mutedButton} borderColor={mutedButtonBorder} borderWidth={1}>
<Link to={helpPath} onClick={onNavigate}>
<XStack alignItems="center" gap="$2">
<LifeBuoy size={16} color={primaryText} />
<Text color={primaryText}>{t('settings.help.cta', 'Open help center')}</Text>
</XStack>
</Link>
</Button>
</YStack>
</Card>
</YStack>
);
}
function ClearCacheButton() {
const { t } = useTranslation();
const [busy, setBusy] = React.useState(false);
const [done, setDone] = React.useState(false);
const { appearance } = useAppearance();
const isDark = appearance === 'dark';
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
const clearAll = React.useCallback(async () => {
setBusy(true);
setDone(false);
try {
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
if ('indexedDB' in window) {
const databases = ['guest-upload-queue', 'upload-queue'];
await Promise.all(
databases.map(
(name) =>
new Promise((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve(null);
request.onerror = () => resolve(null);
})
)
);
}
setDone(true);
} finally {
setBusy(false);
window.setTimeout(() => setDone(false), 2500);
}
}, []);
return (
<YStack gap="$2">
<Button
onPress={clearAll}
disabled={busy}
backgroundColor={mutedButton}
borderColor={mutedButtonBorder}
borderWidth={1}
>
{busy ? t('settings.cache.clearing', 'Clearing cache...') : t('settings.cache.clear', 'Clear cache')}
</Button>
{done ? (
<Text fontSize="$2" color={mutedText}>
{t('settings.cache.cleared', 'Cache cleared.')}
</Text>
) : null}
</YStack>
);
}

View File

@@ -0,0 +1,268 @@
import React from 'react';
import { ScrollView } from '@tamagui/scroll-view';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { ArrowLeft, X } from 'lucide-react';
import SettingsContent from './SettingsContent';
import { useAppearance } from '@/hooks/use-appearance';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { useLocale } from '@/guest/i18n/LocaleContext';
import { LegalMarkdown } from '@/guest/components/legal-markdown';
import type { LocaleCode } from '@/guest/i18n/messages';
const legalLinks = [
{ slug: 'impressum', labelKey: 'settings.legal.section.impressum', fallback: 'Impressum' },
{ slug: 'datenschutz', labelKey: 'settings.legal.section.privacy', fallback: 'Datenschutz' },
{ slug: 'agb', labelKey: 'settings.legal.section.terms', fallback: 'AGB' },
] as const;
type ViewState =
| { mode: 'home' }
| { mode: 'legal'; slug: (typeof legalLinks)[number]['slug']; labelKey: (typeof legalLinks)[number]['labelKey'] };
type LegalDocumentState =
| { phase: 'idle'; title: string; markdown: string; html: string }
| { phase: 'loading'; title: string; markdown: string; html: string }
| { phase: 'ready'; title: string; markdown: string; html: string }
| { phase: 'error'; title: string; markdown: string; html: string };
type SettingsSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export default function SettingsSheet({ open, onOpenChange }: SettingsSheetProps) {
const { t } = useTranslation();
const { locale } = useLocale();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, locale);
const handleBack = React.useCallback(() => setView({ mode: 'home' }), []);
const handleOpenLegal = React.useCallback(
(slug: (typeof legalLinks)[number]['slug'], labelKey: (typeof legalLinks)[number]['labelKey']) => {
setView({ mode: 'legal', slug, labelKey });
},
[]
);
React.useEffect(() => {
if (!open) {
setView({ mode: 'home' });
}
}, [open]);
return (
<>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
left={0}
zIndex={1200}
pointerEvents={open ? 'auto' : 'none'}
style={{
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.2)',
opacity: open ? 1 : 0,
transition: 'opacity 240ms ease',
}}
onPress={() => onOpenChange(false)}
onClick={() => onOpenChange(false)}
onMouseDown={() => onOpenChange(false)}
onTouchStart={() => onOpenChange(false)}
/>
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
zIndex={1300}
width="85%"
maxWidth={420}
backgroundColor={isDark ? '#0B101E' : '#FFFFFF'}
borderTopLeftRadius="$6"
borderBottomLeftRadius="$6"
borderTopRightRadius={0}
borderBottomRightRadius={0}
overflow="hidden"
pointerEvents={open ? 'auto' : 'none'}
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
opacity: open ? 1 : 0,
transition: 'transform 320ms cubic-bezier(0.22, 0.61, 0.36, 1), opacity 220ms ease',
}}
>
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingVertical="$3"
style={{
position: 'sticky',
top: 0,
zIndex: 2,
backgroundColor: isDark ? 'rgba(11, 16, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
borderBottom: isDark ? '1px solid rgba(255, 255, 255, 0.08)' : '1px solid rgba(15, 23, 42, 0.1)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
{isLegal ? (
<XStack alignItems="center" gap="$2">
<Button
size="$3"
circular
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={handleBack}
aria-label={t('common.actions.back', 'Back')}
>
<ArrowLeft size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
<YStack>
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{legalDocument.phase === 'ready' && legalDocument.title
? legalDocument.title
: t(view.labelKey, 'Legal')}
</Text>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{legalDocument.phase === 'loading'
? t('common.actions.loading', 'Loading...')
: t('settings.legal.description', 'Legal notice')}
</Text>
</YStack>
</XStack>
) : (
<Text fontSize="$6" fontFamily="$display" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('settings.title', 'Settings')}
</Text>
)}
<Button
size="$3"
circular
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={() => onOpenChange(false)}
aria-label={t('common.actions.close', 'Close')}
>
<X size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
<ScrollView flex={1} showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 16, paddingBottom: 48 }}>
<YStack gap="$4">
{isLegal ? (
<LegalView
document={legalDocument}
fallbackTitle={t(view.labelKey, 'Legal')}
/>
) : (
<SettingsContent
onNavigate={() => onOpenChange(false)}
showHeader={false}
onOpenLegal={handleOpenLegal}
/>
)}
</YStack>
</ScrollView>
</YStack>
</>
);
}
function LegalView({ document, fallbackTitle }: { document: LegalDocumentState; fallbackTitle: string }) {
const { t } = useTranslation();
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const mutedText = isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)';
if (document.phase === 'error') {
return (
<YStack gap="$3">
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('settings.legal.error', 'Etwas ist schiefgelaufen.')}
</Text>
<Text fontSize="$2" color={mutedText}>
{t('settings.legal.loading', 'Lade...')}
</Text>
</YStack>
);
}
if (document.phase === 'loading' || document.phase === 'idle') {
return (
<Text fontSize="$2" color={mutedText}>
{t('settings.legal.loading', 'Lade...')}
</Text>
);
}
return (
<YStack gap="$3">
<Text fontSize="$5" fontWeight="$8" color={isDark ? '#F8FAFF' : '#0F172A'}>
{document.title || fallbackTitle}
</Text>
<YStack
padding="$3"
borderRadius="$4"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.65)' : 'rgba(255, 255, 255, 0.85)'}
borderColor={isDark ? 'rgba(148, 163, 184, 0.18)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
>
<LegalMarkdown markdown={document.markdown} html={document.html} />
</YStack>
</YStack>
);
}
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
const [state, setState] = React.useState<LegalDocumentState>({
phase: 'idle',
title: '',
markdown: '',
html: '',
});
React.useEffect(() => {
if (!slug) {
setState({ phase: 'idle', title: '', markdown: '', html: '' });
return;
}
const controller = new AbortController();
setState((prev) => ({ ...prev, phase: 'loading' }));
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${encodeURIComponent(locale)}`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
throw new Error('Failed to load legal page');
}
return res.json();
})
.then((data) => {
setState({
phase: 'ready',
title: data?.title ?? '',
markdown: data?.body_markdown ?? '',
html: data?.body_html ?? '',
});
})
.catch((error) => {
if (error?.name === 'AbortError') return;
console.error('Failed to load legal page', error);
setState((prev) => ({ ...prev, phase: 'error' }));
});
return () => controller.abort();
}, [slug, locale]);
return state;
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import AmbientBackground from './AmbientBackground';
type StandaloneShellProps = {
children: React.ReactNode;
compact?: boolean;
};
export default function StandaloneShell({ children, compact = false }: StandaloneShellProps) {
return (
<AmbientBackground>
<YStack minHeight="100vh" padding="$4" paddingTop={compact ? '$4' : '$6'} paddingBottom="$6" gap="$4">
{children}
</YStack>
</AmbientBackground>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { YStack } from '@tamagui/stacks';
import type { YStackProps } from '@tamagui/stacks';
import { useAppearance } from '@/hooks/use-appearance';
type SurfaceCardProps = YStackProps & {
glow?: boolean;
};
export default function SurfaceCard({ children, glow = false, ...props }: SurfaceCardProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
const borderColor = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
const boxShadow = isDark
? glow
? '0 22px 40px rgba(6, 10, 22, 0.55)'
: '0 16px 30px rgba(2, 6, 23, 0.35)'
: glow
? '0 22px 38px rgba(15, 23, 42, 0.16)'
: '0 14px 24px rgba(15, 23, 42, 0.12)';
return (
<YStack
padding="$4"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={borderColor}
style={{ boxShadow }}
{...props}
>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,97 @@
import React from 'react';
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';
type TopBarProps = {
eventName: string;
onProfilePress?: () => void;
onNotificationsPress?: () => void;
notificationCount?: number;
};
export default function TopBar({
eventName,
onProfilePress,
onNotificationsPress,
notificationCount = 0,
}: TopBarProps) {
const { resolved } = useAppearance();
const isDark = resolved === 'dark';
return (
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingVertical="$3"
style={{
backgroundColor: isDark ? 'rgba(10, 14, 28, 0.72)' : 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'saturate(160%) blur(18px)',
WebkitBackdropFilter: 'saturate(160%) blur(18px)',
}}
>
<Text
fontSize="$6"
fontFamily="$display"
fontWeight="$8"
numberOfLines={1}
style={{ textShadow: '0 6px 18px rgba(2, 6, 23, 0.7)' }}
>
{eventName}
</Text>
<XStack gap="$2" alignItems="center">
<Button
size="$3"
circular
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.14)' : 'rgba(15, 23, 42, 0.14)'}
style={{
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)',
boxShadow: isDark ? '0 8px 18px rgba(2, 6, 23, 0.4)' : '0 8px 18px rgba(15, 23, 42, 0.12)',
position: 'relative',
}}
onPress={onNotificationsPress}
>
<Bell size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
{notificationCount > 0 ? (
<span
style={{
position: 'absolute',
top: -2,
right: -2,
width: 18,
height: 18,
borderRadius: 999,
backgroundColor: '#F97316',
color: '#0B101E',
fontSize: 10,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{notificationCount > 9 ? '9+' : notificationCount}
</span>
) : null}
</Button>
<Button
size="$3"
circular
borderWidth={1}
borderColor={isDark ? 'rgba(255, 255, 255, 0.14)' : 'rgba(15, 23, 42, 0.14)'}
style={{
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)',
boxShadow: isDark ? '0 8px 18px rgba(2, 6, 23, 0.4)' : '0 8px 18px rgba(15, 23, 42, 0.12)',
}}
onPress={onProfilePress}
>
<Settings size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
</XStack>
</XStack>
);
}