Files
fotospiel-app/resources/js/guest-v2/components/SettingsContent.tsx
Codex Agent 298a8375b6
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 branding and theming
2026-02-03 15:18:44 +01:00

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>
);
}