added a help system, replaced the words "tenant" and "Pwa" with better alternatives. corrected and implemented cron jobs. prepared going live on a coolify-powered system.

This commit is contained in:
Codex Agent
2025-11-10 16:23:09 +01:00
parent ba9e64dfcb
commit 447a90a742
123 changed files with 6398 additions and 153 deletions

View File

@@ -98,6 +98,7 @@ export type TenantPhoto = {
likes_count: number;
uploaded_at: string;
uploader_name: string | null;
ingest_source?: string | null;
caption?: string | null;
};
@@ -114,6 +115,40 @@ export type EventStats = {
pending_photos?: number;
};
export type PhotoboothStatus = {
enabled: boolean;
status: string | null;
username: string | null;
password: string | null;
path: string | null;
ftp_url: string | null;
expires_at: string | null;
rate_limit_per_minute: number;
ftp: {
host: string | null;
port: number;
require_ftps: boolean;
};
};
export type HelpCenterArticleSummary = {
slug: string;
title: string;
summary: string;
updated_at?: string;
status?: string;
translation_state?: string;
related?: Array<{ slug: string }>;
};
export type HelpCenterArticle = HelpCenterArticleSummary & {
body_html?: string;
body_markdown?: string;
owner?: string;
requires_app_version?: string | null;
version_introduced?: string;
};
export type PaginationMeta = {
current_page: number;
last_page: number;
@@ -184,6 +219,60 @@ export async function fetchOnboardingStatus(): Promise<TenantOnboardingStatus |
}
}
function resolveHelpLocale(locale?: string): 'de' | 'en' {
if (!locale) {
return 'de';
}
const normalized = locale.toLowerCase().split('-')[0];
return normalized === 'en' ? 'en' : 'de';
}
export async function fetchHelpCenterArticles(locale?: string): Promise<HelpCenterArticleSummary[]> {
const resolvedLocale = resolveHelpLocale(locale);
try {
const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale });
const response = await authorizedFetch(`/api/v1/help?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch help articles');
}
const payload = (await response.json()) as { data?: HelpCenterArticleSummary[] };
return Array.isArray(payload?.data) ? payload.data : [];
} catch (error) {
const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.');
emitApiErrorEvent({ message, code: 'help.fetch_list_failed' });
console.error('[HelpApi] Failed to fetch help articles', error);
throw error;
}
}
export async function fetchHelpCenterArticle(slug: string, locale?: string): Promise<HelpCenterArticle> {
const resolvedLocale = resolveHelpLocale(locale);
try {
const params = new URLSearchParams({ audience: 'admin', locale: resolvedLocale });
const response = await authorizedFetch(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch help article');
}
const payload = (await response.json()) as { data?: HelpCenterArticle };
if (!payload?.data) {
throw new Error('Empty help article response');
}
return payload.data;
} catch (error) {
const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.');
emitApiErrorEvent({ message, code: 'help.fetch_detail_failed' });
console.error('[HelpApi] Failed to fetch help article', error);
throw error;
}
}
export type TenantPackageSummary = {
id: number;
package_id: number;
@@ -883,6 +972,38 @@ function eventEndpoint(slug: string): string {
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
}
function photoboothEndpoint(slug: string): string {
return `${eventEndpoint(slug)}/photobooth`;
}
function normalizePhotoboothStatus(payload: JsonValue): PhotoboothStatus {
const ftp = (payload.ftp ?? {}) as JsonValue;
return {
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,
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: {
host: typeof ftp.host === 'string' ? ftp.host : null,
port: Number(ftp.port ?? payload.ftp_port ?? 0) || 0,
require_ftps: Boolean(ftp.require_ftps ?? payload.require_ftps),
},
};
}
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);
const body = (payload as { data?: JsonValue }).data ?? (payload as JsonValue);
return normalizePhotoboothStatus(body ?? {});
}
export async function getEvents(options?: { force?: boolean }): Promise<TenantEvent[]> {
return cachedFetch(
CacheKeys.events,
@@ -1118,6 +1239,22 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
return toolkit;
}
export async function getEventPhotoboothStatus(slug: string): Promise<PhotoboothStatus> {
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 rotateEventPhotobooth(slug: string): Promise<PhotoboothStatus> {
return requestPhotoboothStatus(slug, '/rotate', { method: 'POST' }, '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 submitTenantFeedback(payload: {
category: string;
sentiment?: 'positive' | 'neutral' | 'negative';

View File

@@ -30,3 +30,4 @@ export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/ev
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`);
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`);

View File

@@ -14,7 +14,7 @@
"panel_title": "Team Login für Fotospiel",
"panel_copy": "Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben im Fotospiel-Team zu koordinieren.",
"actions_title": "Wähle deine Anmeldemethode",
"actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Tenant-Dashboard zu.",
"actions_copy": "Greife sicher mit deinem Fotospiel-Login oder deinem Google-Konto auf das Kunden-Dashboard zu.",
"cta": "Mit Fotospiel-Login fortfahren",
"google_cta": "Mit Google anmelden",
"open_account_login": "Konto-Login öffnen",
@@ -24,12 +24,12 @@
"oauth_errors": {
"login_required": "Bitte melde dich zuerst in deinem Fotospiel-Konto an.",
"invalid_request": "Die Login-Anfrage war ungültig. Bitte versuche es erneut.",
"invalid_client": "Die verknüpfte Tenant-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.",
"invalid_client": "Die verknüpfte Kunden-App wurde nicht gefunden. Wende dich an den Support, falls das Problem bleibt.",
"invalid_redirect": "Die angegebene Weiterleitungsadresse ist für diese App nicht hinterlegt.",
"invalid_scope": "Die App fordert Berechtigungen an, die nicht freigegeben sind.",
"tenant_mismatch": "Du hast keinen Zugriff auf den Tenant, der diese Anmeldung angefordert hat.",
"tenant_mismatch": "Du hast keinen Zugriff auf das Kundenkonto, das diese Anmeldung angefordert hat.",
"google_failed": "Die Anmeldung mit Google war nicht erfolgreich. Bitte versuche es erneut oder wähle eine andere Methode.",
"google_no_match": "Wir konnten dieses Google-Konto keinem Tenant-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an."
"google_no_match": "Wir konnten dieses Google-Konto keinem Kunden-Admin zuordnen. Bitte melde dich mit Fotospiel-Zugangsdaten an."
},
"return_hint": "Nach dem Anmelden leiten wir dich automatisch zurück.",
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",

View File

@@ -1,6 +1,6 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"brand": "Fotospiel Kunden-Admin",
"languageSwitch": "Sprache",
"userMenu": "Konto",
"help": "FAQ & Hilfe",

View File

@@ -5,7 +5,7 @@
"guidedSetup": "Guided Setup"
},
"welcome": {
"fallbackName": "Tenant-Admin",
"fallbackName": "Kunden-Admin",
"greeting": "Hallo {{name}}!",
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
},
@@ -24,7 +24,7 @@
},
"overview": {
"title": "Kurzer Überblick",
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
"description": "Wichtigste Kennzahlen deines Nutzerkontos auf einen Blick.",
"noPackage": "Kein aktives Paket",
"stats": {
"activePackage": "Aktives Paket",
@@ -125,7 +125,7 @@
},
"faq": {
"title": "FAQ & Hilfe",
"subtitle": "Antworten und Hinweise rund um den Tenant Admin.",
"subtitle": "Antworten und Hinweise rund um den Kunden-Admin.",
"intro": {
"title": "Was dich erwartet",
"description": "Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt."
@@ -148,6 +148,26 @@
"contact": "Support kontaktieren"
}
},
"helpCenter": {
"title": "Hilfe & Dokumentation",
"subtitle": "Geführte Anleitungen und Troubleshooting für Event-Admins.",
"search": {
"placeholder": "Suche nach Thema oder Stichwort"
},
"list": {
"empty": "Keine Artikel gefunden.",
"error": "Hilfe konnte nicht geladen werden.",
"retry": "Erneut versuchen",
"updated": "Aktualisiert {{date}}"
},
"article": {
"placeholder": "Wähle links einen Artikel aus, um Details zu sehen.",
"loading": "Artikel wird geladen...",
"error": "Artikel konnte nicht geladen werden.",
"updated": "Aktualisiert am {{date}}",
"related": "Verwandte Artikel"
}
},
"dashboard": {
"actions": {
"newEvent": "Neues Event",
@@ -155,7 +175,7 @@
"guidedSetup": "Guided Setup"
},
"welcome": {
"fallbackName": "Tenant-Admin",
"fallbackName": "Kunden-Admin",
"greeting": "Hallo {{name}}!",
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
},
@@ -174,7 +194,7 @@
},
"overview": {
"title": "Kurzer Überblick",
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
"description": "Wichtigste Kennzahlen deines Nutzerkontos auf einen Blick.",
"noPackage": "Kein aktives Paket",
"stats": {
"activePackage": "Aktives Paket",

View File

@@ -60,7 +60,7 @@
},
"transactions": {
"title": "Paddle-Transaktionen",
"description": "Neueste Paddle-Transaktionen für diesen Tenant.",
"description": "Neueste Paddle-Transaktionen für dieses Kundenkonto.",
"empty": "Noch keine Paddle-Transaktionen.",
"labels": {
"transactionId": "Transaktion {{id}}",
@@ -121,7 +121,7 @@
"empty": "Noch keine Events - starte jetzt und lege dein erstes Event an.",
"count": "{{count}} {{count, plural, one {Event} other {Events}}} aktiv verwaltet.",
"badge": {
"dashboard": "Tenant Dashboard"
"dashboard": "Kunden-Dashboard"
}
}
}
@@ -168,7 +168,7 @@
"submit": "Einladung senden"
},
"roles": {
"tenantAdmin": "Tenant-Admin",
"tenantAdmin": "Kunden-Admin",
"member": "Mitglied",
"guest": "Gast"
},

View File

@@ -1,6 +1,6 @@
{
"layout": {
"eyebrow": "Fotospiel Tenant Admin",
"eyebrow": "Fotospiel Kunden-Admin",
"title": "Willkommen im Event-Erlebnisstudio",
"subtitle": "Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie alles optimiert für mobile Hosts.",
"alreadyFamiliar": "Schon vertraut mit Fotospiel?",
@@ -180,7 +180,7 @@
"pendingDescription": "Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket."
},
"free": {
"description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup weitermachen.",
"description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Kundenkonto zuweisen und direkt mit dem Setup weitermachen.",
"activate": "Gratis-Paket aktivieren",
"progress": "Aktivierung läuft …",
"successTitle": "Gratis-Paket aktiviert",

View File

@@ -14,7 +14,7 @@
"panel_title": "Sign in",
"panel_copy": "Sign in with your Fotospiel admin access. Sanctum personal access tokens and clear role permissions keep your account protected.",
"actions_title": "Choose your sign-in method",
"actions_copy": "Access the tenant dashboard securely with your Fotospiel login or your Google account.",
"actions_copy": "Access the customer dashboard securely with your Fotospiel login or your Google account.",
"cta": "Continue with Fotospiel login",
"google_cta": "Continue with Google",
"open_account_login": "Open account login",
@@ -24,12 +24,12 @@
"oauth_errors": {
"login_required": "Please sign in to your Fotospiel account before continuing.",
"invalid_request": "The login request was invalid. Please try again.",
"invalid_client": "We couldnt find the linked tenant app. Please contact support if this persists.",
"invalid_client": "We couldnt find the linked customer app. Please contact support if this persists.",
"invalid_redirect": "The redirect address is not registered for this app.",
"invalid_scope": "The app asked for permissions it cannot receive.",
"tenant_mismatch": "You dont have access to the tenant that requested this login.",
"tenant_mismatch": "You dont have access to the customer account that requested this login.",
"google_failed": "Google sign-in was not successful. Please try again or use another method.",
"google_no_match": "We couldnt link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
"google_no_match": "We couldnt link this Google account to a customer admin. Please sign in with Fotospiel credentials."
},
"return_hint": "After signing in youll be brought back automatically.",
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",

View File

@@ -1,6 +1,6 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"brand": "Fotospiel Customer Admin",
"languageSwitch": "Language",
"userMenu": "Account",
"help": "FAQ & Help",

View File

@@ -5,7 +5,7 @@
"guidedSetup": "Guided setup"
},
"welcome": {
"fallbackName": "Tenant Admin",
"fallbackName": "Customer Admin",
"greeting": "Welcome, {{name}}!",
"subtitle": "Keep your events, packages, and tasks on track."
},
@@ -24,7 +24,7 @@
},
"overview": {
"title": "At a glance",
"description": "Key tenant metrics at a glance.",
"description": "Key customer metrics at a glance.",
"noPackage": "No active package",
"stats": {
"activePackage": "Active package",
@@ -125,7 +125,7 @@
},
"faq": {
"title": "FAQ & Help",
"subtitle": "Answers and hints around the tenant admin.",
"subtitle": "Answers and hints around the customer admin.",
"intro": {
"title": "What to expect",
"description": "We are collecting feedback and will expand this help center step by step."
@@ -148,6 +148,26 @@
"contact": "Contact support"
}
},
"helpCenter": {
"title": "Help & documentation",
"subtitle": "Structured guides and troubleshooting for customer admins.",
"search": {
"placeholder": "Search by topic or keyword"
},
"list": {
"empty": "No articles found.",
"error": "Help could not be loaded.",
"retry": "Try again",
"updated": "Updated {{date}}"
},
"article": {
"placeholder": "Select an article on the left to view details.",
"loading": "Loading article...",
"error": "The article could not be loaded.",
"updated": "Updated on {{date}}",
"related": "Related articles"
}
},
"dashboard": {
"actions": {
"newEvent": "New Event",
@@ -155,7 +175,7 @@
"guidedSetup": "Guided setup"
},
"welcome": {
"fallbackName": "Tenant Admin",
"fallbackName": "Customer Admin",
"greeting": "Welcome, {{name}}!",
"subtitle": "Keep your events, packages, and tasks on track."
},
@@ -174,7 +194,7 @@
},
"overview": {
"title": "At a glance",
"description": "Key tenant metrics at a glance.",
"description": "Key customer metrics at a glance.",
"noPackage": "No active package",
"stats": {
"activePackage": "Active package",

View File

@@ -60,7 +60,7 @@
},
"transactions": {
"title": "Paddle transactions",
"description": "Recent Paddle transactions for this tenant.",
"description": "Recent Paddle transactions for this customer account.",
"empty": "No Paddle transactions yet.",
"labels": {
"transactionId": "Transaction {{id}}",
@@ -121,7 +121,7 @@
"empty": "No events yet create your first one to get started.",
"count": "{{count}} {{count, plural, one {event} other {events}}} managed.",
"badge": {
"dashboard": "Tenant dashboard"
"dashboard": "Customer dashboard"
}
}
}
@@ -168,7 +168,7 @@
"submit": "Send invitation"
},
"roles": {
"tenantAdmin": "Tenant admin",
"tenantAdmin": "Customer admin",
"member": "Member",
"guest": "Guest"
},
@@ -627,11 +627,11 @@
"eventType": "Event type",
"allEventTypes": "All event types",
"globalOnly": "Global templates",
"tenantOnly": "Tenant collections"
"tenantOnly": "Customer collections"
},
"scope": {
"global": "Global template",
"tenant": "Tenant-owned"
"tenant": "Customer-owned"
},
"empty": {
"title": "No collections yet",
@@ -676,7 +676,7 @@
},
"scope": {
"global": "Global",
"tenant": "Tenant"
"tenant": "Customer"
},
"labels": {
"updated": "Updated: {{date}}",

View File

@@ -1,6 +1,6 @@
{
"layout": {
"eyebrow": "Fotospiel Tenant Admin",
"eyebrow": "Fotospiel Customer Admin",
"title": "Welcome to your event studio",
"subtitle": "Begin with an inspired introduction, secure your package, and craft the perfect guest gallery all optimised for mobile hosts.",
"alreadyFamiliar": "Already familiar with Fotospiel?",
@@ -50,7 +50,7 @@
"landingProgress": {
"eyebrow": "Onboarding tracker",
"title": "Stay aligned with your marketing dashboard",
"description": "Complete these quick wins so the marketing dashboard reflects your latest tenant progress.",
"description": "Complete these quick wins so the marketing dashboard reflects your latest customer progress.",
"status": {
"complete": "Completed",
"pending": "Pending"
@@ -180,7 +180,7 @@
"pendingDescription": "You can start preparing the event. An active package is required before going live."
},
"free": {
"description": "This package is free. Assign it to your tenant and continue immediately.",
"description": "This package is free. Assign it to your customer account and continue immediately.",
"activate": "Activate free package",
"progress": "Activating …",
"successTitle": "Free package activated",

View File

@@ -0,0 +1,389 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertCircle, ArrowLeft, Loader2, PlugZap, Power, RefreshCw, ShieldCheck, Copy } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import {
PhotoboothStatus,
TenantEvent,
disableEventPhotobooth,
enableEventPhotobooth,
getEvent,
getEventPhotoboothStatus,
rotateEventPhotobooth,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
type State = {
event: TenantEvent | null;
status: PhotoboothStatus | null;
loading: boolean;
updating: boolean;
error: string | null;
};
export default function EventPhotoboothPage() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']);
const [state, setState] = React.useState<State>({
event: null,
status: null,
loading: true,
updating: false,
error: null,
});
const load = React.useCallback(async () => {
if (!slug) {
setState((prev) => ({
...prev,
loading: false,
error: t('management.photobooth.errors.missingSlug', 'Kein Event ausgewählt.'),
}));
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
setState({
event: eventData,
status: statusData,
loading: false,
updating: false,
error: null,
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
loading: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')),
}));
} else {
setState((prev) => ({ ...prev, loading: false }));
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
async function handleEnable(): Promise<void> {
if (!slug) return;
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await enableEventPhotobooth(slug);
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
async function handleRotate(): Promise<void> {
if (!slug) return;
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await rotateEventPhotobooth(slug);
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
async function handleDisable(): Promise<void> {
if (!slug) return;
if (!window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) {
return;
}
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await disableEventPhotobooth(slug);
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
const { event, status, loading, updating, error } = state;
const title = event
? t('management.photobooth.titleForEvent', { defaultValue: 'Fotobox-Uploads verwalten', event: resolveEventName(event) })
: 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.'
);
const actions = (
<div className="flex gap-2">
{slug ? (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('management.photobooth.actions.backToEvent', 'Zur Detailansicht')}
</Button>
) : null}
<Button variant="ghost" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
{t('management.photobooth.actions.allEvents', 'Zur Eventliste')}
</Button>
</div>
);
return (
<AdminLayout title={title} subtitle={subtitle} actions={actions}>
{error ? (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t('common:messages.error', 'Fehler')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<PhotoboothSkeleton />
) : (
<div className="space-y-6">
<StatusCard status={status} />
<CredentialsCard status={status} updating={updating} onEnable={handleEnable} onRotate={handleRotate} onDisable={handleDisable} />
<RateLimitCard status={status} />
</div>
)}
</AdminLayout>
);
}
function resolveEventName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
return Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function PhotoboothSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-3xl border border-slate-200/80 bg-white/70 p-6 shadow-sm">
<div className="h-4 w-32 animate-pulse rounded bg-slate-200/80" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
<div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
);
}
function StatusCard({ status }: { status: PhotoboothStatus | null }) {
const { t } = useTranslation('management');
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" />;
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle>{t('photobooth.status.heading', 'Status')}</CardTitle>
<CardDescription>
{isActive
? t('photobooth.status.active', 'Photobooth-Link ist aktiv.')
: t('photobooth.status.inactive', 'Noch keine Photobooth-Uploads angebunden.')}
</CardDescription>
</div>
<div className="flex items-center gap-3">
{icon}
<Badge className={badgeColor}>
{isActive ? t('photobooth.status.badgeActive', 'AKTIV') : t('photobooth.status.badgeInactive', 'INAKTIV')}
</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}
</Card>
);
}
type CredentialCardProps = {
status: PhotoboothStatus | null;
updating: boolean;
onEnable: () => Promise<void>;
onRotate: () => Promise<void>;
onDisable: () => Promise<void>;
};
function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) {
const { t } = useTranslation('management');
const isActive = Boolean(status?.enabled);
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>
<CardDescription>
{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>
<div className="flex flex-wrap gap-3">
{isActive ? (
<>
<Button onClick={onRotate} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('photobooth.actions.rotate', 'Zugang neu generieren')}
</Button>
<Button variant="outline" onClick={onDisable} disabled={updating}>
<Power className="mr-2 h-4 w-4" />
{t('photobooth.actions.disable', 'Deaktivieren')}
</Button>
</>
) : (
<Button onClick={onEnable} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlugZap className="mr-2 h-4 w-4" />}
{t('photobooth.actions.enable', 'Photobooth aktivieren')}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
function RateLimitCard({ status }: { status: PhotoboothStatus | null }) {
const { t } = useTranslation('management');
const rateLimit = status?.rate_limit_per_minute ?? 20;
return (
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center gap-3">
<ShieldCheck className="h-5 w-5 text-emerald-500" />
<div>
<CardTitle>{t('photobooth.rateLimit.heading', 'Sicherheit & Limits')}</CardTitle>
<CardDescription>
{t('photobooth.rateLimit.description', 'Uploads werden strikt auf {{count}} Fotos pro Minute begrenzt.', {
count: rateLimit,
})}
</CardDescription>
</div>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-slate-600">
<p>
{t(
'photobooth.rateLimit.body',
'Bei Überschreitung wird die Verbindung hart geblockt. Nach 60 Sekunden wird der Zugang automatisch wieder freigegeben.'
)}
</p>
<p className="mt-3 text-xs text-slate-500">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{t(
'photobooth.rateLimit.hint',
'Ablaufzeit stimmt mit dem Event-Ende überein. Nach Ablauf wird der Account automatisch entfernt.'
)}
</p>
</CardContent>
</Card>
);
}
type FieldProps = {
label: string;
value: string;
copyable?: boolean;
sensitive?: boolean;
className?: string;
};
function Field({ label, value, copyable, sensitive, className }: FieldProps) {
const [copied, setCopied] = React.useState(false);
const showValue = sensitive && value && value !== '—' ? '•'.repeat(Math.min(6, value.length)) : value;
async function handleCopy() {
if (!copyable || !value || value === '—') return;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div className={`rounded-2xl border border-slate-200/80 bg-white/70 p-4 shadow-inner ${className ?? ''}`}>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-base font-medium text-slate-900">{showValue}</span>
{copyable ? (
<Button variant="ghost" size="icon" onClick={handleCopy} aria-label="Copy" disabled={!value || value === '—'}>
{copied ? <ShieldCheck className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4 text-slate-500" />}
</Button>
) : null}
</div>
</div>
);
}

View File

@@ -1,80 +1,245 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, RefreshCcw } from 'lucide-react';
import { AdminLayout } from '../components/AdminLayout';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { HelpCenterArticleSummary, HelpCenterArticle } from '../api';
import { fetchHelpCenterArticles, fetchHelpCenterArticle } from '../api';
function normalizeLocale(language: string | undefined): 'de' | 'en' {
const normalized = (language ?? 'de').toLowerCase().split('-')[0];
return normalized === 'en' ? 'en' : 'de';
}
export default function FaqPage() {
const { t } = useTranslation('dashboard');
const { t, i18n } = useTranslation('dashboard');
const helpLocale = normalizeLocale(i18n.language);
const [query, setQuery] = React.useState('');
const [articles, setArticles] = React.useState<HelpCenterArticleSummary[]>([]);
const [listState, setListState] = React.useState<'loading' | 'ready' | 'error'>('loading');
const [selectedSlug, setSelectedSlug] = React.useState<string | null>(null);
const [detailState, setDetailState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [articleCache, setArticleCache] = React.useState<Record<string, HelpCenterArticle>>({});
const entries = [
{
question: t('faq.events.question', 'Wie arbeite ich mit Events?'),
answer: t(
'faq.events.answer',
'Wähle dein aktives Event, passe Aufgaben an und lade Gäste über die Einladungsseite ein. Weitere Dokumentation folgt bald.'
),
},
{
question: t('faq.uploads.question', 'Wie moderiere ich Uploads?'),
answer: t(
'faq.uploads.answer',
'Sobald Fotos eintreffen, findest du sie in der Galerie-Ansicht deines Events. Von dort kannst du sie freigeben oder zurückweisen.'
),
},
{
question: t('faq.support.question', 'Wo erhalte ich Support?'),
answer: t(
'faq.support.answer',
'Dieses FAQ dient als Platzhalter. Bitte nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank veröffentlicht wird.'
),
},
];
const loadArticles = React.useCallback(async () => {
setListState('loading');
try {
const data = await fetchHelpCenterArticles(helpLocale);
setArticles(data);
setListState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load list', error);
setListState('error');
}
}, [helpLocale]);
React.useEffect(() => {
setArticles([]);
setArticleCache({});
setSelectedSlug(null);
loadArticles();
}, [loadArticles]);
React.useEffect(() => {
if (!selectedSlug && articles.length > 0) {
setSelectedSlug(articles[0].slug);
} else if (selectedSlug && !articles.some((article) => article.slug === selectedSlug)) {
setSelectedSlug(articles[0]?.slug ?? null);
}
}, [articles, selectedSlug]);
const loadArticle = React.useCallback(async (slug: string, options?: { bypassCache?: boolean }) => {
if (!slug) {
return;
}
const bypassCache = options?.bypassCache ?? false;
if (!bypassCache && articleCache[slug]) {
setDetailState('ready');
return;
}
setDetailState('loading');
try {
const article = await fetchHelpCenterArticle(slug, helpLocale);
setArticleCache((prev) => ({ ...prev, [slug]: article }));
setDetailState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load article', error);
setDetailState('error');
}
}, [articleCache, helpLocale]);
React.useEffect(() => {
if (selectedSlug) {
loadArticle(selectedSlug);
}
}, [selectedSlug, loadArticle]);
const filteredArticles = React.useMemo(() => {
if (!query.trim()) {
return articles;
}
const needle = query.trim().toLowerCase();
return articles.filter((article) => `${article.title} ${article.summary}`.toLowerCase().includes(needle));
}, [articles, query]);
const activeArticle = selectedSlug ? articleCache[selectedSlug] : null;
return (
<AdminLayout
title={t('faq.title', 'FAQ & Hilfe')}
subtitle={t('faq.subtitle', 'Antworten und Hinweise rund um den Tenant Admin.')}
title={t('helpCenter.title', 'Hilfe & Dokumentation')}
subtitle={t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}
>
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{t('faq.intro.title', 'Was dich erwartet')}</CardTitle>
<CardDescription>
{t(
'faq.intro.description',
'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.'
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{entries.map((entry) => (
<div key={entry.question} className="rounded-2xl border border-slate-200/80 p-4 dark:border-white/10">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{entry.question}</p>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p>
</div>
))}
<div className="rounded-2xl bg-rose-50/70 p-4 text-sm text-rose-900 dark:bg-rose-200/10 dark:text-rose-100">
<p className="font-semibold">
{t('faq.cta.needHelp', 'Fehlt dir etwas?')}
</p>
<p className="mt-1 text-sm">
{t(
'faq.cta.description',
'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail wir erweitern dieses FAQ mit deinen Themen.'
<div className="grid gap-6 lg:grid-cols-[320px,1fr]">
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{t('helpCenter.title', 'Hilfe & Dokumentation')}</CardTitle>
<CardDescription>{t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder={t('helpCenter.search.placeholder', 'Suche nach Thema')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div>
{listState === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('helpCenter.article.loading', 'Lädt...')}
</div>
)}
</p>
<Button
size="sm"
variant="secondary"
className="mt-3 rounded-full"
onClick={() => window.open('mailto:hello@fotospiel.app', '_blank')}
>
{t('faq.cta.contact', 'Support kontaktieren')}
</Button>
</div>
</CardContent>
</Card>
{listState === 'error' && (
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
<p>{t('helpCenter.list.error')}</p>
<Button variant="secondary" size="sm" onClick={loadArticles}>
<span className="flex items-center gap-2">
<RefreshCcw className="h-4 w-4" />
{t('helpCenter.list.retry')}
</span>
</Button>
</div>
)}
{listState === 'ready' && filteredArticles.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-200/80 p-4 text-sm text-muted-foreground dark:border-white/10">
{t('helpCenter.list.empty')}
</div>
)}
{listState === 'ready' && filteredArticles.length > 0 && (
<div className="space-y-2">
{filteredArticles.map((article) => {
const isActive = selectedSlug === article.slug;
return (
<button
key={article.slug}
type="button"
onClick={() => setSelectedSlug(article.slug)}
className={`w-full rounded-xl border p-3 text-left transition-colors ${
isActive
? 'border-sky-500 bg-sky-500/5 shadow-sm'
: 'border-slate-200/80 hover:border-sky-400/70'
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-slate-900 dark:text-white">{article.title}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300 line-clamp-2">{article.summary}</p>
</div>
{article.updated_at && (
<span className="text-xs text-slate-400 dark:text-slate-500">
{t('helpCenter.list.updated', { date: formatDate(article.updated_at, i18n.language) })}
</span>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
<Card className="border-slate-200 bg-white/95 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{activeArticle?.title ?? t('helpCenter.article.placeholder')}</CardTitle>
<CardDescription className="text-sm text-slate-500 dark:text-slate-300">
{activeArticle?.summary ?? ''}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!selectedSlug && (
<p className="text-sm text-muted-foreground">{t('helpCenter.article.placeholder')}</p>
)}
{selectedSlug && detailState === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('helpCenter.article.loading')}
</div>
)}
{selectedSlug && detailState === 'error' && (
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
<p>{t('helpCenter.article.error')}</p>
<Button size="sm" variant="secondary" onClick={() => loadArticle(selectedSlug, { bypassCache: true })}>
{t('helpCenter.list.retry')}
</Button>
</div>
)}
{detailState === 'ready' && activeArticle && (
<div className="space-y-6">
{activeArticle.updated_at && (
<Badge variant="outline" className="border-slate-200 text-xs font-normal text-slate-600 dark:border-white/20 dark:text-slate-300">
{t('helpCenter.article.updated', { date: formatDate(activeArticle.updated_at, i18n.language) })}
</Badge>
)}
<div
className="prose prose-sm max-w-none text-slate-700 dark:prose-invert"
dangerouslySetInnerHTML={{ __html: activeArticle.body_html ?? activeArticle.body_markdown ?? '' }}
/>
{activeArticle.related && activeArticle.related.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
{t('helpCenter.article.related')}
</h3>
<div className="flex flex-wrap gap-2">
{activeArticle.related.map((rel) => (
<Button
key={rel.slug}
variant="outline"
size="sm"
onClick={() => setSelectedSlug(rel.slug)}
>
{rel.slug}
</Button>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</AdminLayout>
);
}
function formatDate(value: string, language: string | undefined): string {
try {
return new Date(value).toLocaleDateString(language ?? 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch (error) {
return value;
}
}

View File

@@ -46,7 +46,7 @@ export default function SettingsPage() {
t('settings.hero.summary.appearance', { defaultValue: 'Synchronisiere den Look & Feel mit dem Gästeportal oder schalte den Dark Mode frei.' }),
t('settings.hero.summary.notifications', { defaultValue: 'Stimme Benachrichtigungen auf Aufgaben, Pakete und Live-Events ab.' })
];
const accountName = user?.name ?? user?.email ?? 'Tenant Admin';
const accountName = user?.name ?? user?.email ?? 'Customer Admin';
const heroPrimaryAction = (
<Button
size="sm"
@@ -140,7 +140,7 @@ export default function SettingsPage() {
<p className="text-sm text-slate-600">
{user ? (
<>
Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Tenant Admin'}</span>
Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Customer Admin'}</span>
{user.tenant_id && <> - Tenant #{user.tenant_id}</>}
</>
) : (

View File

@@ -21,6 +21,7 @@ const EventMembersPage = React.lazy(() => import('./pages/EventMembersPage'));
const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
const EventPhotoboothPage = React.lazy(() => import('./pages/EventPhotoboothPage'));
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
@@ -107,6 +108,7 @@ export const router = createBrowserRouter([
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
{ path: 'engagement', element: <EngagementPage /> },
{ path: 'tasks', element: <TasksPage /> },