upgrade to tamagui v2 and guest pwa overhaul
This commit is contained in:
28
resources/js/guest-v2/components/AmbientBackground.tsx
Normal file
28
resources/js/guest-v2/components/AmbientBackground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
resources/js/guest-v2/components/AppShell.tsx
Normal file
236
resources/js/guest-v2/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
resources/js/guest-v2/components/BottomDock.tsx
Normal file
75
resources/js/guest-v2/components/BottomDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
resources/js/guest-v2/components/CompassHub.tsx
Normal file
150
resources/js/guest-v2/components/CompassHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
resources/js/guest-v2/components/FabActionSheet.tsx
Normal file
105
resources/js/guest-v2/components/FabActionSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
resources/js/guest-v2/components/FloatingActionButton.tsx
Normal file
55
resources/js/guest-v2/components/FloatingActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
resources/js/guest-v2/components/GuestAnalyticsNudge.tsx
Normal file
267
resources/js/guest-v2/components/GuestAnalyticsNudge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
resources/js/guest-v2/components/NotificationSheet.tsx
Normal file
200
resources/js/guest-v2/components/NotificationSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
resources/js/guest-v2/components/PhotoFrameTile.tsx
Normal file
71
resources/js/guest-v2/components/PhotoFrameTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
resources/js/guest-v2/components/SettingsContent.tsx
Normal file
362
resources/js/guest-v2/components/SettingsContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
resources/js/guest-v2/components/SettingsSheet.tsx
Normal file
268
resources/js/guest-v2/components/SettingsSheet.tsx
Normal 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;
|
||||
}
|
||||
18
resources/js/guest-v2/components/StandaloneShell.tsx
Normal file
18
resources/js/guest-v2/components/StandaloneShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
resources/js/guest-v2/components/SurfaceCard.tsx
Normal file
35
resources/js/guest-v2/components/SurfaceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
resources/js/guest-v2/components/TopBar.tsx
Normal file
97
resources/js/guest-v2/components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user