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:
@@ -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';
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "Fotospiel Tenant Admin",
|
||||
"brand": "Fotospiel Kunden-Admin",
|
||||
"languageSwitch": "Sprache",
|
||||
"userMenu": "Konto",
|
||||
"help": "FAQ & Hilfe",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 couldn’t find the linked tenant app. Please contact support if this persists.",
|
||||
"invalid_client": "We couldn’t 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 don’t have access to the tenant that requested this login.",
|
||||
"tenant_mismatch": "You don’t 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 couldn’t link this Google account to a tenant admin. Please sign in with Fotospiel credentials."
|
||||
"google_no_match": "We couldn’t link this Google account to a customer admin. Please sign in with Fotospiel credentials."
|
||||
},
|
||||
"return_hint": "After signing in you’ll be brought back automatically.",
|
||||
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"brand": "Fotospiel Tenant Admin",
|
||||
"brand": "Fotospiel Customer Admin",
|
||||
"languageSwitch": "Language",
|
||||
"userMenu": "Account",
|
||||
"help": "FAQ & Help",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
389
resources/js/admin/pages/EventPhotoboothPage.tsx
Normal file
389
resources/js/admin/pages/EventPhotoboothPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}</>}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
|
||||
export type GalleryFilter = 'latest' | 'popular' | 'mine';
|
||||
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
|
||||
export default function FiltersBar({ value, onChange }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void }) {
|
||||
return (
|
||||
@@ -10,8 +10,8 @@ export default function FiltersBar({ value, onChange }: { value: GalleryFilter;
|
||||
<ToggleGroupItem value="latest">Neueste</ToggleGroupItem>
|
||||
<ToggleGroupItem value="popular">Beliebt</ToggleGroupItem>
|
||||
<ToggleGroupItem value="mine">Meine</ToggleGroupItem>
|
||||
<ToggleGroupItem value="photobooth">Fotobox</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,21 @@ import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
|
||||
type Props = { token: string };
|
||||
|
||||
type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
|
||||
export default function GalleryPreview({ token }: Props) {
|
||||
const { photos, loading } = usePollGalleryDelta(token);
|
||||
const [mode, setMode] = React.useState<'latest' | 'popular' | 'myphotos'>('latest');
|
||||
const [mode, setMode] = React.useState<PreviewFilter>('latest');
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
let arr = photos.slice();
|
||||
|
||||
// MyPhotos filter (requires session_id matching)
|
||||
if (mode === 'myphotos') {
|
||||
if (mode === 'mine') {
|
||||
const deviceId = getDeviceId();
|
||||
arr = arr.filter((photo: any) => photo.session_id === deviceId);
|
||||
} else if (mode === 'photobooth') {
|
||||
arr = arr.filter((photo: any) => photo.ingest_source === 'photobooth');
|
||||
}
|
||||
|
||||
// Sorting
|
||||
@@ -71,15 +75,25 @@ export default function GalleryPreview({ token }: Props) {
|
||||
Popular
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('myphotos')}
|
||||
onClick={() => setMode('mine')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
||||
mode === 'myphotos'
|
||||
mode === 'mine'
|
||||
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
|
||||
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
|
||||
}`}
|
||||
>
|
||||
My Photos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('photobooth')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
|
||||
mode === 'photobooth'
|
||||
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
|
||||
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
|
||||
}`}
|
||||
>
|
||||
Fotobox
|
||||
</button>
|
||||
</div>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
|
||||
Alle ansehen →
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -13,7 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
|
||||
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle, LifeBuoy } from 'lucide-react';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { LegalMarkdown } from './legal-markdown';
|
||||
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
|
||||
@@ -48,11 +49,13 @@ export function SettingsSheet() {
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const localeContext = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ token?: string }>();
|
||||
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
|
||||
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
|
||||
const [savingName, setSavingName] = React.useState(false);
|
||||
const isLegal = view.mode === 'legal';
|
||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
|
||||
const helpHref = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && identity?.hydrated) {
|
||||
@@ -170,6 +173,7 @@ export function SettingsSheet() {
|
||||
nameStatus={nameStatus}
|
||||
localeContext={localeContext}
|
||||
onOpenLegal={handleOpenLegal}
|
||||
helpHref={helpHref}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
@@ -241,6 +245,7 @@ interface HomeViewProps {
|
||||
slug: (typeof legalPages)[number]['slug'],
|
||||
translationKey: (typeof legalPages)[number]['translationKey'],
|
||||
) => void;
|
||||
helpHref: string;
|
||||
}
|
||||
|
||||
function HomeView({
|
||||
@@ -254,6 +259,7 @@ function HomeView({
|
||||
nameStatus,
|
||||
localeContext,
|
||||
onOpenLegal,
|
||||
helpHref,
|
||||
}: HomeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const legalLinks = React.useMemo(
|
||||
@@ -374,6 +380,23 @@ function HomeView({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoy className="h-4 w-4 text-pink-500" />
|
||||
{t('settings.help.title')}
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.help.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link to={helpHref}>{t('settings.help.cta')}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.cache.title')}</CardTitle>
|
||||
|
||||
@@ -430,6 +430,11 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
cleared: 'Cache gelöscht.',
|
||||
note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.',
|
||||
},
|
||||
help: {
|
||||
title: 'Hilfe & Support',
|
||||
description: 'Öffne das Hilfecenter mit Schritt-für-Schritt-Anleitungen.',
|
||||
cta: 'Hilfecenter öffnen',
|
||||
},
|
||||
footer: {
|
||||
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
|
||||
},
|
||||
@@ -439,6 +444,26 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
legalDescription: 'Rechtlicher Hinweis',
|
||||
},
|
||||
},
|
||||
help: {
|
||||
center: {
|
||||
title: 'Hilfe & Tipps',
|
||||
subtitle: 'Antworten für Gäste – nach dem ersten Laden auch offline verfügbar.',
|
||||
searchPlaceholder: 'Suche nach Thema oder Stichwort',
|
||||
offlineBadge: 'Offline-Version',
|
||||
offlineDescription: 'Du siehst eine zwischengespeicherte Version. Geh online für aktuelle Inhalte.',
|
||||
empty: 'Keine Artikel gefunden.',
|
||||
error: 'Hilfe konnte nicht geladen werden.',
|
||||
retry: 'Erneut versuchen',
|
||||
listTitle: 'Alle Artikel',
|
||||
},
|
||||
article: {
|
||||
back: 'Zurück zur Übersicht',
|
||||
updated: 'Aktualisiert am {date}',
|
||||
relatedTitle: 'Verwandte Artikel',
|
||||
unavailable: 'Dieser Artikel ist nicht verfügbar.',
|
||||
reload: 'Neu laden',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
common: {
|
||||
@@ -858,6 +883,11 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
cleared: 'Cache cleared.',
|
||||
note: 'This only affects this browser and must be repeated per device.',
|
||||
},
|
||||
help: {
|
||||
title: 'Help & support',
|
||||
description: 'Open the help center for guides and quick answers.',
|
||||
cta: 'Open help center',
|
||||
},
|
||||
footer: {
|
||||
notice: 'Guest area - data is stored locally in the browser.',
|
||||
},
|
||||
@@ -867,6 +897,26 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
legalDescription: 'Legal notice',
|
||||
},
|
||||
},
|
||||
help: {
|
||||
center: {
|
||||
title: 'Help & tips',
|
||||
subtitle: 'Guides for guests – available offline after the first sync.',
|
||||
searchPlaceholder: 'Search by topic or keyword',
|
||||
offlineBadge: 'Offline copy',
|
||||
offlineDescription: 'You are viewing cached content. Go online to refresh articles.',
|
||||
empty: 'No articles found.',
|
||||
error: 'Help could not be loaded.',
|
||||
retry: 'Try again',
|
||||
listTitle: 'All articles',
|
||||
},
|
||||
article: {
|
||||
back: 'Back to overview',
|
||||
updated: 'Updated on {date}',
|
||||
relatedTitle: 'Related articles',
|
||||
unavailable: 'This article is unavailable.',
|
||||
reload: 'Reload',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,11 +13,19 @@ import PhotoLightbox from './PhotoLightbox';
|
||||
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
|
||||
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
||||
|
||||
const parseGalleryFilter = (value: string | null): GalleryFilter =>
|
||||
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { token } = useParams<{ token?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(token ?? '');
|
||||
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const photoIdParam = searchParams.get('photoId');
|
||||
const modeParam = searchParams.get('mode');
|
||||
const [filter, setFilterState] = React.useState<GalleryFilter>(() => parseGalleryFilter(modeParam));
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||
|
||||
@@ -28,8 +36,16 @@ export default function GalleryPage() {
|
||||
const { t } = useTranslation();
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const photoIdParam = searchParams.get('photoId');
|
||||
useEffect(() => {
|
||||
setFilterState(parseGalleryFilter(modeParam));
|
||||
}, [modeParam]);
|
||||
|
||||
const setFilter = React.useCallback((next: GalleryFilter) => {
|
||||
setFilterState(next);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('mode', next);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
// Auto-open lightbox if photoId in query params
|
||||
useEffect(() => {
|
||||
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) {
|
||||
@@ -79,6 +95,9 @@ export default function GalleryPage() {
|
||||
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
} else if (filter === 'mine') {
|
||||
arr = arr.filter((p: any) => myPhotoIds.has(p.id));
|
||||
} else if (filter === 'photobooth') {
|
||||
arr = arr.filter((p: any) => p.ingest_source === 'photobooth');
|
||||
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
} else {
|
||||
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
}
|
||||
|
||||
121
resources/js/guest/pages/HelpArticlePage.tsx
Normal file
121
resources/js/guest/pages/HelpArticlePage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Page } from './_util';
|
||||
import { useLocale } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { getHelpArticle, type HelpArticleDetail } from '../services/helpApi';
|
||||
|
||||
export default function HelpArticlePage() {
|
||||
const params = useParams<{ token?: string; slug: string }>();
|
||||
const slug = params.slug;
|
||||
const { locale } = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
|
||||
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [servedFromCache, setServedFromCache] = React.useState(false);
|
||||
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
||||
|
||||
const loadArticle = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
setState('loading');
|
||||
try {
|
||||
const result = await getHelpArticle(slug, locale);
|
||||
setArticle(result.article);
|
||||
setServedFromCache(result.servedFromCache);
|
||||
setState('ready');
|
||||
} catch (error) {
|
||||
console.error('[HelpArticle] Failed to load article', error);
|
||||
setState('error');
|
||||
}
|
||||
}, [slug, locale]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadArticle();
|
||||
}, [loadArticle]);
|
||||
|
||||
const title = article?.title ?? t('help.article.unavailable');
|
||||
|
||||
return (
|
||||
<Page title={title}>
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={basePath}>
|
||||
{t('help.article.back')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p>{t('help.article.unavailable')}</p>
|
||||
<Button variant="secondary" size="sm" onClick={loadArticle}>
|
||||
{t('help.article.reload')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && article && (
|
||||
<article className="space-y-6">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
{article.updated_at && (
|
||||
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
|
||||
)}
|
||||
{servedFromCache && (
|
||||
<Badge variant="secondary" className="bg-amber-200/70 text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
|
||||
/>
|
||||
{article.related && article.related.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{article.related.map((rel) => (
|
||||
<Button
|
||||
key={rel.slug}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
|
||||
{rel.slug}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string, locale: string): string {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
153
resources/js/guest/pages/HelpCenterPage.tsx
Normal file
153
resources/js/guest/pages/HelpCenterPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, RefreshCcw } from 'lucide-react';
|
||||
import { Page } from './_util';
|
||||
import { useLocale } from '../i18n/LocaleContext';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { getHelpArticles, type HelpArticleSummary } from '../services/helpApi';
|
||||
|
||||
export default function HelpCenterPage() {
|
||||
const params = useParams<{ token?: string }>();
|
||||
const { locale } = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const [articles, setArticles] = React.useState<HelpArticleSummary[]>([]);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading');
|
||||
const [servedFromCache, setServedFromCache] = React.useState(false);
|
||||
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
||||
|
||||
const loadArticles = React.useCallback(async (forceRefresh = false) => {
|
||||
setState('loading');
|
||||
try {
|
||||
const result = await getHelpArticles(locale, { forceRefresh });
|
||||
setArticles(result.articles);
|
||||
setServedFromCache(result.servedFromCache);
|
||||
setState('ready');
|
||||
} catch (error) {
|
||||
console.error('[HelpCenter] Failed to load articles', error);
|
||||
setState('error');
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadArticles();
|
||||
}, [loadArticles]);
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Page title={t('help.center.title')}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t('help.center.subtitle')}</p>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
placeholder={t('help.center.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="flex-1"
|
||||
aria-label={t('help.center.searchPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="sm:w-auto"
|
||||
onClick={() => loadArticles(true)}
|
||||
disabled={state === 'loading'}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{t('help.center.retry')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{servedFromCache && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
</Badge>
|
||||
<span>{t('help.center.offlineDescription')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-foreground">{t('help.center.listTitle')}</h2>
|
||||
{state === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.actions.loading')}
|
||||
</div>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p>{t('help.center.error')}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => loadArticles(false)}
|
||||
>
|
||||
{t('help.center.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
|
||||
{t('help.center.empty')}
|
||||
</div>
|
||||
)}
|
||||
{state === 'ready' && filteredArticles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{filteredArticles.map((article) => (
|
||||
<Link
|
||||
key={article.slug}
|
||||
to={`${basePath}/${encodeURIComponent(article.slug)}`}
|
||||
className="block rounded-2xl border border-border/60 bg-card/70 p-4 transition-colors hover:border-primary/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">{article.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-3">{article.summary}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{article.updated_at ? formatDate(article.updated_at, locale) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string, locale: string): string {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,14 @@ export function usePollGalleryDelta(token: string) {
|
||||
const json = await res.json();
|
||||
|
||||
// Handle different response formats
|
||||
const newPhotos = Array.isArray(json.data) ? json.data :
|
||||
const rawPhotos = Array.isArray(json.data) ? json.data :
|
||||
Array.isArray(json) ? json :
|
||||
json.photos || [];
|
||||
|
||||
const newPhotos = rawPhotos.map((photo: any) => ({
|
||||
...photo,
|
||||
session_id: photo?.session_id ?? photo?.guest_name ?? null,
|
||||
}));
|
||||
|
||||
if (newPhotos.length > 0) {
|
||||
const added = newPhotos.length;
|
||||
|
||||
@@ -27,6 +27,8 @@ const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
|
||||
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
|
||||
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
|
||||
const LegalPage = React.lazy(() => import('./pages/LegalPage'));
|
||||
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
|
||||
const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage'));
|
||||
const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage'));
|
||||
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
|
||||
|
||||
@@ -75,10 +77,14 @@ export const router = createBrowserRouter([
|
||||
{ path: 'photo/:photoId', element: <PhotoLightbox /> },
|
||||
{ path: 'achievements', element: <AchievementsPage /> },
|
||||
{ path: 'slideshow', element: <SlideshowPage /> },
|
||||
{ path: 'help', element: <HelpCenterPage /> },
|
||||
{ path: 'help/:slug', element: <HelpArticlePage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/settings', element: <SimpleLayout title="Einstellungen"><SettingsPage /></SimpleLayout> },
|
||||
{ path: '/legal/:page', element: <SimpleLayout title="Rechtliches"><LegalPage /></SimpleLayout> },
|
||||
{ path: '/help', element: <HelpStandalone /> },
|
||||
{ path: '/help/:slug', element: <HelpArticleStandalone /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
]);
|
||||
|
||||
@@ -248,3 +254,21 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
|
||||
</EventBrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpStandalone() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SimpleLayout title={t('help.center.title')}>
|
||||
<HelpCenterPage />
|
||||
</SimpleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpArticleStandalone() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SimpleLayout title={t('help.center.title')}>
|
||||
<HelpArticlePage />
|
||||
</SimpleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
160
resources/js/guest/services/helpApi.ts
Normal file
160
resources/js/guest/services/helpApi.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
export type HelpArticleSummary = {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
version_introduced?: string;
|
||||
requires_app_version?: string | null;
|
||||
status?: string;
|
||||
translation_state?: string;
|
||||
last_reviewed_at?: string;
|
||||
owner?: string;
|
||||
updated_at?: string;
|
||||
related?: Array<{ slug: string }>;
|
||||
};
|
||||
|
||||
export type HelpArticleDetail = HelpArticleSummary & {
|
||||
body_markdown?: string;
|
||||
body_html?: string;
|
||||
source_path?: string;
|
||||
};
|
||||
|
||||
export interface HelpListResult {
|
||||
articles: HelpArticleSummary[];
|
||||
servedFromCache: boolean;
|
||||
}
|
||||
|
||||
export interface HelpArticleResult {
|
||||
article: HelpArticleDetail;
|
||||
servedFromCache: boolean;
|
||||
}
|
||||
|
||||
const AUDIENCE = 'guest';
|
||||
const LIST_CACHE_TTL = 1000 * 60 * 60 * 6; // 6 hours
|
||||
const DETAIL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
|
||||
|
||||
interface CacheRecord<T> {
|
||||
storedAt: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
function listCacheKey(locale: LocaleCode): string {
|
||||
return `help:list:${AUDIENCE}:${locale}`;
|
||||
}
|
||||
|
||||
function detailCacheKey(locale: LocaleCode, slug: string): string {
|
||||
return `help:article:${AUDIENCE}:${locale}:${slug}`;
|
||||
}
|
||||
|
||||
function readCache<T>(key: string, ttl: number): CacheRecord<T> | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as CacheRecord<T>;
|
||||
if (!parsed?.data || !parsed?.storedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - parsed.storedAt > ttl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn('[HelpApi] Failed to read cache', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache<T>(key: string, data: T): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: CacheRecord<T> = {
|
||||
storedAt: Date.now(),
|
||||
data,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
console.warn('[HelpApi] Failed to write cache', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error('Help request failed');
|
||||
(error as any).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function getHelpArticles(locale: LocaleCode, options?: { forceRefresh?: boolean }): Promise<HelpListResult> {
|
||||
const cacheKey = listCacheKey(locale);
|
||||
const cached = readCache<HelpArticleSummary[]>(cacheKey, LIST_CACHE_TTL);
|
||||
|
||||
if (cached && !options?.forceRefresh) {
|
||||
return { articles: cached.data, servedFromCache: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
audience: AUDIENCE,
|
||||
locale,
|
||||
});
|
||||
const data = await requestJson<{ data?: HelpArticleSummary[] }>(`/api/v1/help?${params.toString()}`);
|
||||
const articles = Array.isArray(data?.data) ? data.data : [];
|
||||
writeCache(cacheKey, articles);
|
||||
return { articles, servedFromCache: false };
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
return { articles: cached.data, servedFromCache: true };
|
||||
}
|
||||
console.error('[HelpApi] Failed to fetch help articles', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHelpArticle(slug: string, locale: LocaleCode): Promise<HelpArticleResult> {
|
||||
const cacheKey = detailCacheKey(locale, slug);
|
||||
const cached = readCache<HelpArticleDetail>(cacheKey, DETAIL_CACHE_TTL);
|
||||
|
||||
if (cached) {
|
||||
return { article: cached.data, servedFromCache: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
audience: AUDIENCE,
|
||||
locale,
|
||||
});
|
||||
const data = await requestJson<{ data?: HelpArticleDetail }>(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`);
|
||||
const article = data?.data ?? { slug, title: slug, summary: '' };
|
||||
writeCache(cacheKey, article);
|
||||
return { article, servedFromCache: false };
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
return { article: cached.data, servedFromCache: true };
|
||||
}
|
||||
console.error('[HelpApi] Failed to fetch help article', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section heading="Service Controls">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@foreach($services as $service)
|
||||
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $service['label'] }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $service['service_id'] }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100">
|
||||
{{ ucfirst($service['status'] ?? 'unknown') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<x-filament::button size="sm" color="warning" wire:click="restart('{{ $service['service_id'] }}')">
|
||||
Restart
|
||||
</x-filament::button>
|
||||
<x-filament::button size="sm" color="gray" wire:click="redeploy('{{ $service['service_id'] }}')">
|
||||
Redeploy
|
||||
</x-filament::button>
|
||||
@if($coolifyWebUrl)
|
||||
<x-filament::button tag="a" size="sm" color="gray" href="{{ $coolifyWebUrl }}/services/{{ $service['service_id'] }}" target="_blank">
|
||||
Open in Coolify
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section heading="Recent Actions">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th class="px-3 py-2">When</th>
|
||||
<th class="px-3 py-2">User</th>
|
||||
<th class="px-3 py-2">Service</th>
|
||||
<th class="px-3 py-2">Action</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@forelse($recentLogs as $log)
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-slate-700 dark:text-slate-200">{{ $log['created_at'] }}</td>
|
||||
<td class="px-3 py-2 text-slate-600">{{ $log['user'] }}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ $log['service_id'] }}</td>
|
||||
<td class="px-3 py-2">{{ ucfirst($log['action']) }}</td>
|
||||
<td class="px-3 py-2">{{ $log['status_code'] ?? '—' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-3 py-4 text-center text-slate-500">No actions recorded yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<x-filament::link href="{{ route('filament.superadmin.resources.coolify-action-logs.index') }}" class="mt-3 inline-flex text-sm text-primary-600">
|
||||
View full log →
|
||||
</x-filament::link>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,48 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section heading="Infra Status (Coolify)">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@forelse($services as $service)
|
||||
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $service['label'] }}</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $service['service_id'] }}</p>
|
||||
</div>
|
||||
<span @class([
|
||||
'rounded-full px-3 py-1 text-xs font-semibold',
|
||||
'bg-emerald-100 text-emerald-800' => $service['status'] === 'running',
|
||||
'bg-amber-100 text-amber-800' => $service['status'] === 'deploying',
|
||||
'bg-rose-100 text-rose-800' => $service['status'] === 'unreachable' || $service['status'] === 'error',
|
||||
'bg-slate-100 text-slate-600' => ! in_array($service['status'], ['running', 'deploying', 'unreachable', 'error']),
|
||||
])>
|
||||
{{ ucfirst($service['status']) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(isset($service['error']))
|
||||
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $service['error'] }}</p>
|
||||
@else
|
||||
<dl class="mt-3 grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">CPU</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">{{ $service['cpu'] ?? '—' }}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">Memory</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">{{ $service['memory'] ?? '—' }}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500 dark:text-slate-400">Last Deploy</dt>
|
||||
<dd class="font-semibold text-slate-900 dark:text-white">
|
||||
{{ $service['last_deploy'] ? \Illuminate\Support\Carbon::parse($service['last_deploy'])->diffForHumans() : '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-slate-500 dark:text-slate-300">No Coolify services configured.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
Reference in New Issue
Block a user