geschenkgutscheine implementiert ("Paket verschenken"). Neuer Upload-Provider: Sparkbooth.

This commit is contained in:
Codex Agent
2025-12-07 16:54:58 +01:00
parent 3f3c0f1d35
commit 046e2fe3ec
50 changed files with 2422 additions and 130 deletions

View File

@@ -149,13 +149,26 @@ export type PhotoboothStatusMetrics = {
last_upload_at?: string | null;
};
export type SparkboothStatus = {
enabled: boolean;
status: string | null;
username: string | null;
password: string | null;
expires_at: string | null;
upload_url: string | null;
response_format: 'json' | 'xml';
metrics?: PhotoboothStatusMetrics | null;
};
export type PhotoboothStatus = {
mode: 'ftp' | 'sparkbooth';
enabled: boolean;
status: string | null;
username: string | null;
password: string | null;
path: string | null;
ftp_url: string | null;
upload_url: string | null;
expires_at: string | null;
rate_limit_per_minute: number;
ftp: {
@@ -163,6 +176,7 @@ export type PhotoboothStatus = {
port: number;
require_ftps: boolean;
};
sparkbooth?: SparkboothStatus | null;
metrics?: PhotoboothStatusMetrics | null;
};
@@ -1215,13 +1229,34 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus {
};
}
const sparkRaw = (payload.sparkbooth ?? null) as JsonValue | null;
let sparkbooth: SparkboothStatus | null = null;
if (sparkRaw && typeof sparkRaw === 'object') {
sparkbooth = {
enabled: Boolean((sparkRaw as JsonValue).enabled),
status: typeof (sparkRaw as JsonValue).status === 'string' ? (sparkRaw as JsonValue).status : null,
username: typeof (sparkRaw as JsonValue).username === 'string' ? (sparkRaw as JsonValue).username : null,
password: typeof (sparkRaw as JsonValue).password === 'string' ? (sparkRaw as JsonValue).password : null,
expires_at: typeof (sparkRaw as JsonValue).expires_at === 'string' ? (sparkRaw as JsonValue).expires_at : null,
upload_url: typeof (sparkRaw as JsonValue).upload_url === 'string' ? (sparkRaw as JsonValue).upload_url : null,
response_format:
(sparkRaw as JsonValue).response_format === 'xml' ? 'xml' : 'json',
metrics: normalizePhotoboothMetrics((sparkRaw as JsonValue).metrics),
};
}
const modeValue = typeof payload.mode === 'string' && payload.mode === 'sparkbooth' ? 'sparkbooth' : 'ftp';
return {
mode: modeValue,
enabled: Boolean(payload.enabled),
status: typeof payload.status === 'string' ? payload.status : null,
username: typeof payload.username === 'string' ? payload.username : null,
password: typeof payload.password === 'string' ? payload.password : null,
path: typeof payload.path === 'string' ? payload.path : null,
ftp_url: typeof payload.ftp_url === 'string' ? payload.ftp_url : null,
upload_url: typeof payload.upload_url === 'string' ? payload.upload_url : null,
expires_at: typeof payload.expires_at === 'string' ? payload.expires_at : null,
rate_limit_per_minute: Number(payload.rate_limit_per_minute ?? ftp.rate_limit_per_minute ?? 0),
ftp: {
@@ -1229,10 +1264,34 @@ function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus {
port: Number(ftp.port ?? payload.ftp_port ?? 0) || 0,
require_ftps: Boolean(ftp.require_ftps ?? payload.require_ftps),
},
sparkbooth,
metrics,
};
}
function normalizePhotoboothMetrics(raw: JsonValue | null | undefined): PhotoboothStatusMetrics | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, JsonValue>;
const readNumber = (key: string): number | null => {
const value = record[key];
if (value === null || value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
return {
uploads_last_hour: readNumber('uploads_last_hour') ?? readNumber('last_hour') ?? readNumber('hour'),
uploads_today: readNumber('uploads_today') ?? readNumber('today'),
uploads_total: readNumber('uploads_total') ?? readNumber('total'),
last_upload_at: typeof record.last_upload_at === 'string' ? record.last_upload_at : null,
};
}
async function requestPhotoboothStatus(slug: string, path = '', init: RequestInit = {}, errorMessage = 'Failed to fetch photobooth status'): Promise<PhotoboothStatus> {
const response = await authorizedFetch(`${photoboothEndpoint(slug)}${path}`, init);
const payload = await jsonOrThrow<JsonValue | { data: JsonValue }>(response, errorMessage);
@@ -1648,16 +1707,40 @@ export async function getEventPhotoboothStatus(slug: string): Promise<Photobooth
return requestPhotoboothStatus(slug, '', {}, 'Failed to load photobooth status');
}
export async function enableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
return requestPhotoboothStatus(slug, '/enable', { method: 'POST' }, 'Failed to enable photobooth access');
export async function enableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined;
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
return requestPhotoboothStatus(
slug,
'/enable',
{ method: 'POST', body, headers },
'Failed to enable photobooth access'
);
}
export async function rotateEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, 'Failed to rotate credentials');
export async function rotateEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined;
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
return requestPhotoboothStatus(
slug,
'/rotate',
{ method: 'POST', body, headers },
'Failed to rotate credentials'
);
}
export async function disableEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
return requestPhotoboothStatus(slug, '/disable', { method: 'POST' }, 'Failed to disable photobooth access');
export async function disableEventPhotobooth(slug: string, options?: { mode?: 'ftp' | 'sparkbooth' }): Promise<PhotoboothStatus> {
const body = options?.mode ? JSON.stringify({ mode: options.mode }) : undefined;
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
return requestPhotoboothStatus(
slug,
'/disable',
{ method: 'POST', body, headers },
'Failed to disable photobooth access'
);
}
export async function submitTenantFeedback(payload: {

View File

@@ -937,16 +937,26 @@
"inactive": "Noch keine Photobooth-Uploads angebunden.",
"badgeActive": "AKTIV",
"badgeInactive": "INAKTIV",
"expiresAt": "Automatisches Abschalten am {{date}}"
"expiresAt": "Automatisches Abschalten am {{date}}",
"mode": "Modus"
},
"mode": {
"title": "Photobooth-Typ auswählen",
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
"active": "Aktuell: {{mode}}"
},
"credentials": {
"heading": "FTP-Zugangsdaten",
"description": "Teile die Zugangsdaten mit eurer Photobooth-Software.",
"sparkboothTitle": "Sparkbooth-Upload (HTTP)",
"sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).",
"host": "Host",
"port": "Port",
"username": "Benutzername",
"password": "Passwort",
"path": "Upload-Pfad"
"path": "Upload-Pfad",
"postUrl": "Upload-URL",
"responseFormat": "Antwort-Format"
},
"actions": {
"enable": "Photobooth aktivieren",
@@ -992,7 +1002,7 @@
"planTitle": "Planungsmodus",
"planDescription": "Zugang bleibt deaktiviert, um Tests vorzubereiten.",
"liveTitle": "Live-Modus",
"liveDescription": "FTP ist aktiv und Uploads werden direkt angenommen.",
"liveDescription": "Zugang bleibt aktiv (FTP oder Sparkbooth) und Uploads werden direkt verarbeitet.",
"badgePlan": "Planung",
"badgeLive": "Live",
"current": "Aktiv",
@@ -1598,4 +1608,4 @@
"ctaFallback": "Events ansehen"
}
}
}
}

View File

@@ -702,16 +702,26 @@
"inactive": "No photobooth uploads connected yet.",
"badgeActive": "ACTIVE",
"badgeInactive": "INACTIVE",
"expiresAt": "Will switch off automatically on {{date}}"
"expiresAt": "Will switch off automatically on {{date}}",
"mode": "Mode"
},
"mode": {
"title": "Choose your photobooth type",
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
"active": "Current: {{mode}}"
},
"credentials": {
"heading": "FTP credentials",
"description": "Share these credentials with your photobooth software.",
"sparkboothTitle": "Sparkbooth upload (HTTP)",
"sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).",
"host": "Host",
"port": "Port",
"username": "Username",
"password": "Password",
"path": "Upload path"
"path": "Upload path",
"postUrl": "Upload URL",
"responseFormat": "Response format"
},
"actions": {
"enable": "Activate photobooth",
@@ -757,7 +767,7 @@
"planTitle": "Planning mode",
"planDescription": "Keep the FTP account disabled while preparing the booth.",
"liveTitle": "Live mode",
"liveDescription": "FTP access stays enabled and uploads are processed instantly.",
"liveDescription": "Access stays enabled and uploads are processed instantly (FTP or Sparkbooth).",
"badgePlan": "Planning",
"badgeLive": "Live",
"current": "Active",

View File

@@ -40,6 +40,8 @@ export default function EventPhotoboothPage() {
const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']);
const [mode, setMode] = React.useState<'ftp' | 'sparkbooth'>('ftp');
const [state, setState] = React.useState<State>({
event: null,
status: null,
@@ -96,17 +98,27 @@ export default function EventPhotoboothPage() {
void load();
}, [load]);
async function handleEnable(): Promise<void> {
React.useEffect(() => {
if (state.status?.mode) {
setMode(state.status.mode);
}
}, [state.status?.mode]);
async function handleEnable(targetMode?: 'ftp' | 'sparkbooth'): Promise<void> {
if (!slug) return;
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await enableEventPhotobooth(slug);
const selectedMode = targetMode ?? mode;
const result = await enableEventPhotobooth(slug, { mode: selectedMode });
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
if (result.mode) {
setMode(result.mode);
}
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
@@ -125,7 +137,7 @@ export default function EventPhotoboothPage() {
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await rotateEventPhotobooth(slug);
const result = await rotateEventPhotobooth(slug, { mode });
setState((prev) => ({
...prev,
status: result,
@@ -153,7 +165,7 @@ export default function EventPhotoboothPage() {
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await disableEventPhotobooth(slug);
const result = await disableEventPhotobooth(slug, { mode });
setState((prev) => ({
...prev,
status: result,
@@ -178,7 +190,7 @@ export default function EventPhotoboothPage() {
: t('management.photobooth.title', 'Fotobox-Uploads');
const subtitle = t(
'management.photobooth.subtitle',
'Erstelle einen einfachen FTP-Link für Photobooth-Software. Rate-Limit: 20 Fotos/Minute.'
'Erstelle einen einfachen Photobooth-Link per FTP oder Sparkbooth-Upload. Rate-Limit: 20 Fotos/Minute.'
);
const eventTabs = React.useMemo(() => {
if (!event || !slug) {
@@ -192,7 +204,10 @@ export default function EventPhotoboothPage() {
}, [event, slug, t]);
const recentPhotos = React.useMemo(() => toolkit?.photos?.recent ?? [], [toolkit?.photos?.recent]);
const photoboothRecent = React.useMemo(() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth'), [recentPhotos]);
const photoboothRecent = React.useMemo(
() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth' || photo.ingest_source === 'sparkbooth'),
[recentPhotos]
);
const effectiveRecentPhotos = React.useMemo(
() => (photoboothRecent.length > 0 ? photoboothRecent : recentPhotos),
[photoboothRecent, recentPhotos],
@@ -260,6 +275,42 @@ export default function EventPhotoboothPage() {
<PhotoboothSkeleton />
) : (
<div className="space-y-6">
<div className="rounded-3xl border border-slate-200/80 bg-white/70 p-5 shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
{t('management.photobooth.mode.title', 'Photobooth-Typ auswählen')}
</p>
<p className="text-xs text-slate-500">
{t(
'management.photobooth.mode.description',
'Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.'
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={mode === 'ftp' ? 'default' : 'outline'}
onClick={() => handleEnable('ftp')}
disabled={updating || mode === 'ftp'}
>
FTP (Classic)
</Button>
<Button
variant={mode === 'sparkbooth' ? 'default' : 'outline'}
onClick={() => handleEnable('sparkbooth')}
disabled={updating || mode === 'sparkbooth'}
>
Sparkbooth (HTTP)
</Button>
</div>
</div>
<p className="mt-3 text-xs text-slate-500">
{t('management.photobooth.mode.active', 'Aktuell: {{mode}}', {
mode: mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP',
})}
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<StatusCard status={status} />
<SetupChecklistCard status={status} />
@@ -338,7 +389,7 @@ function ModePresetsCard({ status, updating, onEnable, onDisable, onRotate }: Mo
{
key: 'live' as const,
title: t('photobooth.presets.liveTitle', 'Live-Modus'),
description: t('photobooth.presets.liveDescription', 'FTP ist aktiv und Uploads werden direkt entgegen genommen.'),
description: t('photobooth.presets.liveDescription', 'Uploads sind aktiv (FTP oder Sparkbooth) und werden direkt verarbeitet.'),
badge: t('photobooth.presets.badgeLive', 'Live'),
icon: <PlugZap className="h-5 w-5 text-emerald-500" />,
},
@@ -524,6 +575,7 @@ function StatusCard({ status }: { status: PhotoboothStatus | null }) {
const isActive = Boolean(status?.enabled);
const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800';
const icon = isActive ? <PlugZap className="h-5 w-5 text-emerald-500" /> : <Power className="h-5 w-5 text-slate-400" />;
const modeLabel = status?.mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP';
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
@@ -543,13 +595,18 @@ function StatusCard({ status }: { status: PhotoboothStatus | null }) {
</Badge>
</div>
</CardHeader>
{status?.expires_at ? (
<CardContent className="text-sm text-slate-600">
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
date: new Date(status.expires_at).toLocaleString(),
})}
</CardContent>
) : null}
<CardContent className="space-y-1 text-sm text-slate-600">
<p className="text-xs uppercase tracking-wide text-slate-500">
{t('photobooth.status.mode', 'Modus')}: {modeLabel}
</p>
{status?.expires_at ? (
<p>
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
date: new Date(status.expires_at).toLocaleString(),
})}
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -565,27 +622,48 @@ type CredentialCardProps = {
function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) {
const { t } = useTranslation('management');
const isActive = Boolean(status?.enabled);
const isSparkbooth = status?.mode === 'sparkbooth';
return (
<Card className="rounded-3xl border border-rose-100/80 shadow-lg shadow-rose-100/40">
<CardHeader>
<CardTitle>{t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}</CardTitle>
<CardTitle>
{isSparkbooth ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth-Upload (HTTP)') : t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}
</CardTitle>
<CardDescription>
{t(
'photobooth.credentials.description',
'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.'
)}
{isSparkbooth
? t(
'photobooth.credentials.sparkboothDescription',
'Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten erfolgen als JSON (optional XML).'
)
: t(
'photobooth.credentials.description',
'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.'
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
</div>
{isSparkbooth ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
<Field label={t('photobooth.credentials.postUrl', 'Upload-URL')} value={status?.upload_url ?? '—'} copyable className="md:col-span-2" />
<Field
label={t('photobooth.credentials.responseFormat', 'Antwort-Format')}
value={status?.sparkbooth?.response_format === 'xml' ? 'XML' : 'JSON'}
className="md:col-span-2"
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
</div>
)}
<div className="flex flex-wrap gap-3">
{isActive ? (