193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Page } from './_util';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Html5Qrcode } from 'html5-qrcode';
|
|
import { readGuestName } from '../context/GuestIdentityContext';
|
|
import { useTranslation } from '../i18n/useTranslation';
|
|
|
|
type LandingErrorKey = 'eventClosed' | 'network' | 'camera';
|
|
|
|
export default function LandingPage() {
|
|
const nav = useNavigate();
|
|
const { t } = useTranslation();
|
|
const [eventCode, setEventCode] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [errorKey, setErrorKey] = useState<LandingErrorKey | null>(null);
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
|
|
const errorMessage = errorKey ? t(`landing.errors.${errorKey}`) : null;
|
|
|
|
function extractEventKey(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;
|
|
}
|
|
|
|
async function join(input?: string) {
|
|
const provided = input ?? eventCode;
|
|
const normalized = extractEventKey(provided);
|
|
if (!normalized) return;
|
|
setLoading(true);
|
|
setErrorKey(null);
|
|
try {
|
|
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
|
|
if (!res.ok) {
|
|
setErrorKey('eventClosed');
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
const targetKey = data.join_token ?? data.slug ?? normalized;
|
|
const storedName = readGuestName(targetKey);
|
|
if (!storedName) {
|
|
nav(`/setup/${encodeURIComponent(targetKey)}`);
|
|
} else {
|
|
nav(`/e/${encodeURIComponent(targetKey)}`);
|
|
}
|
|
} catch (e) {
|
|
console.error('Join request failed', e);
|
|
setErrorKey('network');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const;
|
|
|
|
async function startScanner() {
|
|
if (scanner) {
|
|
try {
|
|
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
|
|
setIsScanning(true);
|
|
} catch (err) {
|
|
console.error('Scanner start failed', err);
|
|
setErrorKey('camera');
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const newScanner = new Html5Qrcode('qr-reader');
|
|
setScanner(newScanner);
|
|
await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
|
|
setIsScanning(true);
|
|
} catch (err) {
|
|
console.error('Scanner initialisation failed', err);
|
|
setErrorKey('camera');
|
|
}
|
|
}
|
|
|
|
function stopScanner() {
|
|
if (!scanner) {
|
|
setIsScanning(false);
|
|
return;
|
|
}
|
|
scanner
|
|
.stop()
|
|
.then(() => {
|
|
setIsScanning(false);
|
|
})
|
|
.catch((err) => console.error('Scanner stop failed', err));
|
|
}
|
|
|
|
async function onScanSuccess(decodedText: string) {
|
|
const value = decodedText.trim();
|
|
if (!value) return;
|
|
await join(value);
|
|
stopScanner();
|
|
}
|
|
|
|
useEffect(() => () => {
|
|
if (scanner) {
|
|
scanner.stop().catch(() => undefined);
|
|
}
|
|
}, [scanner]);
|
|
|
|
return (
|
|
<Page title={t('landing.pageTitle')}>
|
|
{errorMessage && (
|
|
<Alert className="mb-3" variant="destructive">
|
|
<AlertDescription>{errorMessage}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<div className="space-y-6 pb-20">
|
|
<div className="text-center space-y-2">
|
|
<h1 className="text-2xl font-bold text-gray-900">{t('landing.headline')}</h1>
|
|
<p className="text-lg text-gray-600">{t('landing.subheadline')}</p>
|
|
</div>
|
|
|
|
<Card className="mx-auto w-full max-w-md">
|
|
<CardHeader className="text-center">
|
|
<CardTitle className="text-xl font-semibold">{t('landing.join.title')}</CardTitle>
|
|
<CardDescription>{t('landing.join.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 p-6">
|
|
<div className="flex flex-col items-center space-y-3">
|
|
<div className="flex h-24 w-24 items-center justify-center rounded-lg bg-gray-200">
|
|
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
|
</svg>
|
|
</div>
|
|
<div id="qr-reader" className="w-full" hidden={!isScanning} />
|
|
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={isScanning ? stopScanner : startScanner}
|
|
disabled={loading}
|
|
>
|
|
{isScanning ? t('landing.scan.stop') : t('landing.scan.start')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
|
|
{t('landing.scan.manualDivider')}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Input
|
|
value={eventCode}
|
|
onChange={(event) => setEventCode(event.target.value)}
|
|
placeholder={t('landing.input.placeholder')}
|
|
disabled={loading}
|
|
/>
|
|
<Button
|
|
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
|
|
disabled={loading || !eventCode.trim()}
|
|
onClick={() => join()}
|
|
>
|
|
{loading ? t('landing.join.buttonLoading') : t('landing.join.button')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</Page>
|
|
);
|
|
}
|