Files
fotospiel-app/resources/js/admin/mobile/EventPhotoboothPage.tsx
Codex Agent 3e36354916
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Restructure photobooth page flow
2026-01-13 10:52:50 +01:00

452 lines
18 KiB
TypeScript

import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3, Mail, Download } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import {
getEvent,
getEventPhotoboothStatus,
enableEventPhotobooth,
disableEventPhotobooth,
rotateEventPhotobooth,
createEventPhotoboothConnectCode,
sendEventPhotoboothUploaderEmail,
PhotoboothStatus,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, formatEventDateTime } from '../lib/events';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
export default function MobileEventPhotoboothPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const { text, muted, border, surface, danger } = useAdminTheme();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
const [loading, setLoading] = React.useState(true);
const [updating, setUpdating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [connectCode, setConnectCode] = React.useState<string | null>(null);
const [connectExpiresAt, setConnectExpiresAt] = React.useState<string | null>(null);
const [connectLoading, setConnectLoading] = React.useState(false);
const [sendingEmail, setSendingEmail] = React.useState(false);
const [showCredentials, setShowCredentials] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const load = React.useCallback(async () => {
if (!slug) return;
setLoading(true);
setError(null);
try {
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
setEvent(eventData);
setStatus(statusData);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
const handleEnable = async () => {
if (!slug) return;
const nextMode = 'sparkbooth';
setUpdating(true);
try {
const result = await enableEventPhotobooth(slug, { mode: nextMode });
setStatus(result);
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')));
}
} finally {
setUpdating(false);
}
};
const handleDisable = async () => {
if (!slug) return;
const mode = 'sparkbooth';
setUpdating(true);
try {
const result = await disableEventPhotobooth(slug, { mode });
setStatus(result);
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')));
}
} finally {
setUpdating(false);
}
};
const handleRotate = async () => {
if (!slug) return;
const mode = 'sparkbooth';
setUpdating(true);
try {
const result = await rotateEventPhotobooth(slug, { mode });
setStatus(result);
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')));
}
} finally {
setUpdating(false);
}
};
const handleGenerateConnectCode = async () => {
if (!slug) return;
setConnectLoading(true);
try {
const result = await createEventPhotoboothConnectCode(slug);
setConnectCode(result.code || null);
setConnectExpiresAt(result.expires_at ?? null);
toast.success(t('photobooth.connectCode.actions.generated', 'Verbindungscode erstellt'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(
getApiErrorMessage(err, t('photobooth.connectCode.errors.failed', 'Verbindungscode konnte nicht erstellt werden.'))
);
}
} finally {
setConnectLoading(false);
}
};
const handleSendDownloadEmail = async () => {
if (!slug) return;
setSendingEmail(true);
try {
await sendEventPhotoboothUploaderEmail(slug);
toast.success(t('photobooth.uploaderDownload.emailSuccess', 'Download-Links wurden per E-Mail gesendet.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(
getApiErrorMessage(err, t('photobooth.uploaderDownload.emailFailed', 'E-Mail konnte nicht gesendet werden.'))
);
}
} finally {
setSendingEmail(false);
}
};
const spark = status?.sparkbooth ?? null;
const metrics = spark?.metrics ?? null;
const expiresAt = spark?.expires_at ?? status?.expires_at;
const lastUploadAt = metrics?.last_upload_at;
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
const uploadsTotal = metrics?.uploads_total;
const uploadUrl = spark?.upload_url ?? status?.upload_url;
const username = spark?.username ?? status?.username ?? null;
const password = spark?.password ?? status?.password ?? null;
const modeLabel = t('photobooth.mode.uploader', 'Uploader App (HTTP)');
const isActive = Boolean(status?.enabled);
const title = t('photobooth.title', 'Photobooth');
return (
<MobileShell
activeTab="home"
title={title}
onBack={back}
headerActions={
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
<RefreshCcw size={18} color={text} />
</HeaderActionButton>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<SkeletonCard key={`ph-skel-${idx}`} height={110} />
))}
</YStack>
) : (
<YStack space="$2">
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
</Text>
</YStack>
<XStack alignItems="center" justifyContent="space-between" space="$3" flexWrap="wrap">
<PillBadge tone={isActive ? 'success' : 'warning'}>
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
</Text>
</XStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable())}
tone={isActive ? 'ghost' : 'primary'}
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
disabled={updating}
fullWidth={false}
/>
{isActive ? (
<CTAButton
label={t('photobooth.actions.rotate', 'Regenerate access')}
onPress={() => handleRotate()}
tone="ghost"
iconLeft={<RefreshCw size={14} color={text} />}
disabled={updating}
fullWidth={false}
/>
) : null}
</XStack>
</MobileCard>
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'photobooth.uploaderDownload.description',
'Die Fotospiel Uploader App ist verpflichtend, damit Uploads stabil laufen, die Zugangsdaten geschuetzt bleiben und keine Dateien verloren gehen.'
)}
</Text>
</YStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<CTAButton
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')}
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-win-x64.exe', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
iconLeft={<Download size={14} color={surface} />}
fullWidth={false}
/>
<CTAButton
label={t('photobooth.uploaderDownload.actionMac', 'Uploader herunterladen (macOS)')}
tone="ghost"
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-macos-x64', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
fullWidth={false}
/>
<CTAButton
label={t('photobooth.uploaderDownload.actionLinux', 'Uploader herunterladen (Linux)')}
tone="ghost"
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-linux-x64', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
fullWidth={false}
/>
</XStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={
sendingEmail
? t('common.processing', '...')
: t('photobooth.uploaderDownload.emailAction', 'Download-Links per E-Mail senden')
}
tone="ghost"
onPress={handleSendDownloadEmail}
iconLeft={<Mail size={14} color={text} />}
disabled={sendingEmail}
fullWidth={false}
/>
</XStack>
</MobileCard>
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.steps.access.description', 'Der Code verbindet die App sicher mit deinem Event.')}
</Text>
</YStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={
connectLoading
? t('common.processing', '...')
: t('photobooth.connectCode.actions.generate', 'Generate connect code')
}
onPress={handleGenerateConnectCode}
iconLeft={<PlugZap size={14} color={surface} />}
disabled={!isActive || updating || connectLoading}
fullWidth={false}
/>
<CTAButton
label={
showCredentials
? t('photobooth.credentials.hide', 'Hide credentials')
: t('photobooth.credentials.show', 'Show credentials')
}
tone="ghost"
onPress={() => setShowCredentials((current) => !current)}
fullWidth={false}
/>
</XStack>
<YStack space="$2" marginTop="$2">
{connectCode ? (
<CredentialRow label={t('photobooth.connectCode.label', 'Connect code')} value={connectCode} border={border} />
) : null}
{connectExpiresAt ? (
<Text fontSize="$xs" color={muted}>
{t('photobooth.connectCode.expires', 'Expires: {{date}}', {
date: formatEventDateTime(connectExpiresAt, locale),
})}
</Text>
) : null}
{showCredentials ? (
<YStack space="$1">
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('photobooth.credentials.hidden', 'Credentials are hidden. Tap to show them.')}
</Text>
)}
<Text fontSize="$xs" color={muted}>
{t('photobooth.uploader.hint', 'POST with media file or base64 "media" field; app uses these credentials.')}
</Text>
</YStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.status.heading', 'Status')}
</Text>
<YStack space="$1">
<StatusRow icon={<ShieldCheck size={16} color={text} />} label={t('photobooth.status.mode', 'Mode')} value={modeLabel} />
<StatusRow
icon={<PlugZap size={16} color={text} />}
label={t('photobooth.status.heading', 'Status')}
value={isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
/>
<StatusRow
icon={<RefreshCcw size={16} color={text} />}
label={t('photobooth.rateLimit.label', 'Rate limit (uploads/min)')}
value={status?.rate_limit_per_minute != null ? String(status.rate_limit_per_minute) : '—'}
/>
<StatusRow
icon={<Clock3 size={16} color={text} />}
label={t('photobooth.status.expires', 'Access expires')}
value={expiresAt ? formatEventDate(expiresAt, locale) ?? '—' : '—'}
/>
{lastUploadAt ? (
<StatusRow
icon={<Clock3 size={16} color={text} />}
label={t('photobooth.stats.lastUpload', 'Last upload')}
value={formatEventDate(lastUploadAt, locale) ?? '—'}
/>
) : null}
{uploads24h != null ? (
<StatusRow
icon={<RefreshCcw size={16} color={text} />}
label={t('photobooth.stats.uploads24h', 'Uploads last 24h')}
value={String(uploads24h)}
/>
) : null}
{uploadsTotal != null ? (
<StatusRow
icon={<RefreshCcw size={16} color={text} />}
label={t('photobooth.stats.uploadsTotal', 'Uploads total')}
value={String(uploadsTotal)}
/>
) : null}
</YStack>
</MobileCard>
</YStack>
)}
</MobileShell>
);
}
function CredentialRow({ label, value, border, masked }: { label: string; value: string; border: string; masked?: boolean }) {
const { t } = useTranslation('management');
const { muted, text } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between" borderWidth={1} borderColor={border} borderRadius="$3" padding="$2">
<YStack>
<Text fontSize="$xs" color={muted}>
{label}
</Text>
<Text fontSize="$sm" fontWeight="700" color={text}>
{masked ? '••••••••' : value}
</Text>
</YStack>
<Pressable
onPress={async () => {
try {
await navigator.clipboard.writeText(value);
toast.success(t('common.copied', 'Kopiert'));
} catch {
toast.error(t('common.copyFailed', 'Kopieren fehlgeschlagen'));
}
}}
>
<Copy size={16} color={muted} />
</Pressable>
</XStack>
);
}
function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
const { text } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2">
{icon}
<Text fontSize="$sm" color={text}>
{label}
</Text>
</XStack>
<Text fontSize="$sm" fontWeight="700" color={text}>
{value}
</Text>
</XStack>
);
}