363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
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';
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
|
|
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 { isDark } = useGuestThemeVariant();
|
|
const { token } = useEventData();
|
|
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 { isDark } = useGuestThemeVariant();
|
|
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>
|
|
);
|
|
}
|