267 lines
9.2 KiB
TypeScript
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;
|
|
}
|