Files
fotospiel-app/resources/js/guest-v2/components/SettingsSheet.tsx
2026-02-03 15:18:44 +01:00

267 lines
9.2 KiB
TypeScript

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 { useGuestThemeVariant } from '../lib/guestTheme';
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 { isDark } = useGuestThemeVariant();
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 { isDark } = useGuestThemeVariant();
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;
}