245 lines
8.3 KiB
TypeScript
245 lines
8.3 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate } 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 { Html5Qrcode } from 'html5-qrcode';
|
|
import { QrCode, ArrowRight } from 'lucide-react';
|
|
import { useTranslation } from '@/guest/i18n/useTranslation';
|
|
import { fetchEvent } from '../services/eventApi';
|
|
import { readGuestName } from '../context/GuestIdentityContext';
|
|
import EventLogo from '../components/EventLogo';
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
|
|
const qrConfig = { fps: 10, qrbox: { width: 240, height: 240 } } as const;
|
|
|
|
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
|
|
|
|
export default function LandingScreen() {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation();
|
|
const [eventCode, setEventCode] = React.useState('');
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [errorKey, setErrorKey] = React.useState<LandingErrorKey | null>(null);
|
|
const [isScanning, setIsScanning] = React.useState(false);
|
|
const scannerRef = React.useRef<Html5Qrcode | null>(null);
|
|
const { isDark } = useGuestThemeVariant();
|
|
const cardBackground = isDark ? 'rgba(15, 23, 42, 0.7)' : 'rgba(255, 255, 255, 0.9)';
|
|
const cardBorder = isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
|
const primaryText = isDark ? '#F8FAFF' : '#0F172A';
|
|
const mutedText = isDark ? 'rgba(226, 232, 240, 0.78)' : 'rgba(15, 23, 42, 0.6)';
|
|
const mutedButton = isDark ? 'rgba(248, 250, 255, 0.1)' : 'rgba(15, 23, 42, 0.06)';
|
|
const mutedButtonBorder = isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
|
const brandName = 'Fotospiel';
|
|
|
|
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
|
|
|
|
const extractEventKey = React.useCallback((raw: string): string => {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
const url = new URL(trimmed);
|
|
const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token');
|
|
if (inviteParam) {
|
|
return inviteParam;
|
|
}
|
|
const segments = url.pathname.split('/').filter(Boolean);
|
|
const eventIndex = segments.findIndex((segment) => segment === 'e');
|
|
if (eventIndex >= 0 && segments.length > eventIndex + 1) {
|
|
return decodeURIComponent(segments[eventIndex + 1]);
|
|
}
|
|
if (segments.length > 0) {
|
|
return decodeURIComponent(segments[segments.length - 1]);
|
|
}
|
|
} catch {
|
|
// Not a URL, treat as raw code.
|
|
}
|
|
|
|
return trimmed;
|
|
}, []);
|
|
|
|
const join = React.useCallback(
|
|
async (input?: string) => {
|
|
const provided = input ?? eventCode;
|
|
const normalized = extractEventKey(provided);
|
|
if (!normalized) return;
|
|
|
|
setLoading(true);
|
|
setErrorKey(null);
|
|
|
|
try {
|
|
const event = await fetchEvent(normalized);
|
|
const targetKey = event.join_token ?? normalized;
|
|
if (!targetKey) {
|
|
setErrorKey('eventClosed');
|
|
return;
|
|
}
|
|
|
|
const storedName = readGuestName(targetKey);
|
|
if (!storedName) {
|
|
navigate(`/setup/${encodeURIComponent(targetKey)}`);
|
|
} else {
|
|
navigate(`/e/${encodeURIComponent(targetKey)}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Join request failed', error);
|
|
setErrorKey('network');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[eventCode, extractEventKey, navigate]
|
|
);
|
|
|
|
const onScanSuccess = React.useCallback(
|
|
async (decodedText: string) => {
|
|
const value = decodedText.trim();
|
|
if (!value) return;
|
|
await join(value);
|
|
if (scannerRef.current) {
|
|
try {
|
|
await scannerRef.current.stop();
|
|
} catch (error) {
|
|
console.warn('Scanner stop failed', error);
|
|
}
|
|
}
|
|
setIsScanning(false);
|
|
},
|
|
[join]
|
|
);
|
|
|
|
const startScanner = React.useCallback(async () => {
|
|
if (scannerRef.current) {
|
|
try {
|
|
await scannerRef.current.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
|
|
setIsScanning(true);
|
|
} catch (error) {
|
|
console.error('Scanner start failed', error);
|
|
setErrorKey('camera');
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const scanner = new Html5Qrcode('qr-reader');
|
|
scannerRef.current = scanner;
|
|
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
|
|
setIsScanning(true);
|
|
} catch (error) {
|
|
console.error('Scanner init failed', error);
|
|
setErrorKey('camera');
|
|
}
|
|
}, [onScanSuccess]);
|
|
|
|
const stopScanner = React.useCallback(async () => {
|
|
if (!scannerRef.current) {
|
|
setIsScanning(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await scannerRef.current.stop();
|
|
} catch (error) {
|
|
console.warn('Scanner stop failed', error);
|
|
} finally {
|
|
setIsScanning(false);
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => () => {
|
|
if (scannerRef.current) {
|
|
scannerRef.current.stop().catch(() => undefined);
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<YStack flex={1} minHeight="100vh" padding="$5" justifyContent="center" backgroundColor={isDark ? '#0B101E' : '#FFF8F5'}>
|
|
<YStack gap="$5" maxWidth={480} width="100%" alignSelf="center">
|
|
<XStack alignItems="center" gap="$3">
|
|
<EventLogo name={brandName} size="s" />
|
|
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color={primaryText}>
|
|
{brandName}
|
|
</Text>
|
|
</XStack>
|
|
<YStack gap="$2">
|
|
<Text fontSize="$8" fontFamily="$display" fontWeight="$9" color={primaryText}>
|
|
{t('landing.pageTitle')}
|
|
</Text>
|
|
<Text color={mutedText}>
|
|
{t('landing.subheadline')}
|
|
</Text>
|
|
</YStack>
|
|
|
|
{errorMessage ? (
|
|
<Card padding="$3" backgroundColor="rgba(248, 113, 113, 0.12)" borderColor="rgba(248, 113, 113, 0.4)" borderWidth={1}>
|
|
<Text color="#FEE2E2">{errorMessage}</Text>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Card padding="$4" backgroundColor={cardBackground} borderColor={cardBorder} borderWidth={1}>
|
|
<YStack gap="$4">
|
|
<XStack alignItems="center" gap="$2">
|
|
<QrCode size={18} color={primaryText} />
|
|
<Text fontSize="$5" fontWeight="$8" color={primaryText}>
|
|
{t('landing.join.title')}
|
|
</Text>
|
|
</XStack>
|
|
<Text color={mutedText}>
|
|
{t('landing.join.description')}
|
|
</Text>
|
|
|
|
<YStack gap="$3">
|
|
<YStack
|
|
id="qr-reader"
|
|
height={240}
|
|
borderRadius="$4"
|
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.55)' : 'rgba(15, 23, 42, 0.05)'}
|
|
borderWidth={1}
|
|
borderColor={isDark ? 'rgba(148, 163, 184, 0.15)' : 'rgba(15, 23, 42, 0.12)'}
|
|
overflow="hidden"
|
|
/>
|
|
<Button
|
|
onPress={isScanning ? stopScanner : startScanner}
|
|
backgroundColor={mutedButton}
|
|
borderColor={mutedButtonBorder}
|
|
borderWidth={1}
|
|
>
|
|
{isScanning ? t('landing.scan.stop', 'Scanner stoppen') : t('landing.scan.start', 'QR-Code scannen')}
|
|
</Button>
|
|
</YStack>
|
|
|
|
<Text color={mutedText} textAlign="center">
|
|
{t('landing.scan.manualDivider')}
|
|
</Text>
|
|
|
|
<YStack gap="$3">
|
|
<Input
|
|
value={eventCode}
|
|
onChangeText={setEventCode}
|
|
placeholder={t('landing.input.placeholder')}
|
|
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.05)'}
|
|
borderColor={cardBorder}
|
|
color={primaryText}
|
|
/>
|
|
<Button
|
|
onPress={() => join()}
|
|
disabled={loading || !eventCode.trim()}
|
|
backgroundColor="$primary"
|
|
color="#FFFFFF"
|
|
iconAfter={loading ? undefined : () => <ArrowRight size={16} color="#FFFFFF" />}
|
|
>
|
|
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
|
|
</Button>
|
|
</YStack>
|
|
</YStack>
|
|
</Card>
|
|
</YStack>
|
|
</YStack>
|
|
);
|
|
}
|