Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
import { ApiError } from './lib/apiError';
|
||||
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||
import i18n from './i18n';
|
||||
|
||||
type JsonValue = Record<string, unknown>;
|
||||
@@ -62,6 +63,7 @@ export type TenantEvent = {
|
||||
purchased_at: string | null;
|
||||
expires_at: string | null;
|
||||
} | null;
|
||||
limits?: EventLimitSummary | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -128,6 +130,13 @@ export type TenantPackageSummary = {
|
||||
package_limits: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type NotificationPreferences = Record<string, boolean>;
|
||||
|
||||
export type NotificationPreferencesMeta = {
|
||||
credit_warning_sent_at?: string | null;
|
||||
credit_warning_threshold?: number | null;
|
||||
};
|
||||
|
||||
export type CreditBalance = {
|
||||
balance: number;
|
||||
free_event_granted_at?: string | null;
|
||||
@@ -490,6 +499,7 @@ function normalizeEvent(event: JsonValue): TenantEvent {
|
||||
engagement_mode: engagementMode,
|
||||
settings,
|
||||
package: event.package ?? null,
|
||||
limits: (event.limits ?? null) as EventLimitSummary | null,
|
||||
};
|
||||
|
||||
return normalized;
|
||||
@@ -779,10 +789,17 @@ export async function getEventTypes(): Promise<TenantEventType[]> {
|
||||
.filter((row): row is TenantEventType => Boolean(row));
|
||||
}
|
||||
|
||||
export async function getEventPhotos(slug: string): Promise<TenantPhoto[]> {
|
||||
export async function getEventPhotos(slug: string): Promise<{ photos: TenantPhoto[]; limits: EventLimitSummary | null }> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`);
|
||||
const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos');
|
||||
return (data.data ?? []).map(normalizePhoto);
|
||||
const data = await jsonOrThrow<{ data?: TenantPhoto[]; limits?: EventLimitSummary | null }>(
|
||||
response,
|
||||
'Failed to load photos'
|
||||
);
|
||||
|
||||
return {
|
||||
photos: (data.data ?? []).map(normalizePhoto),
|
||||
limits: (data.limits ?? null) as EventLimitSummary | null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function featurePhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||
@@ -1049,6 +1066,56 @@ export async function getTenantPackagesOverview(): Promise<{
|
||||
return { packages, activePackage };
|
||||
}
|
||||
|
||||
export type NotificationPreferenceResponse = {
|
||||
defaults: NotificationPreferences;
|
||||
preferences: NotificationPreferences;
|
||||
overrides: NotificationPreferences | null;
|
||||
meta: NotificationPreferencesMeta | null;
|
||||
};
|
||||
|
||||
export async function getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/settings/notifications');
|
||||
const payload = await jsonOrThrow<{ data?: { defaults?: NotificationPreferences; preferences?: NotificationPreferences; overrides?: NotificationPreferences; meta?: NotificationPreferencesMeta } }>(
|
||||
response,
|
||||
'Failed to load notification preferences'
|
||||
);
|
||||
|
||||
const data = payload.data ?? {};
|
||||
|
||||
return {
|
||||
defaults: data.defaults ?? {},
|
||||
preferences: data.preferences ?? {},
|
||||
overrides: data.overrides ?? null,
|
||||
meta: data.meta ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateNotificationPreferences(
|
||||
preferences: NotificationPreferences
|
||||
): Promise<NotificationPreferenceResponse> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/settings/notifications', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ preferences }),
|
||||
});
|
||||
|
||||
const payload = await jsonOrThrow<{ data?: { preferences?: NotificationPreferences; overrides?: NotificationPreferences; meta?: NotificationPreferencesMeta } }>(
|
||||
response,
|
||||
'Failed to update notification preferences'
|
||||
);
|
||||
|
||||
const data = payload.data ?? {};
|
||||
|
||||
return {
|
||||
defaults: {},
|
||||
preferences: data.preferences ?? preferences,
|
||||
overrides: data.overrides ?? null,
|
||||
meta: data.meta ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
data: PaddleTransactionSummary[];
|
||||
nextCursor: string | null;
|
||||
|
||||
@@ -28,5 +28,18 @@
|
||||
"creditsExhausted": "Keine Event-Credits mehr verfügbar. Bitte buche Credits oder upgrade dein Paket.",
|
||||
"photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.",
|
||||
"goToBilling": "Zur Paketverwaltung"
|
||||
},
|
||||
"limits": {
|
||||
"photosTitle": "Foto-Limit",
|
||||
"photosWarning": "Nur noch {remaining} von {limit} Foto-Uploads verfügbar.",
|
||||
"photosBlocked": "Foto-Uploads sind blockiert. Bitte Paket upgraden oder erweitern.",
|
||||
"guestsTitle": "Gäste-Limit",
|
||||
"guestsWarning": "Nur noch {remaining} von {limit} Gästelinks verfügbar.",
|
||||
"guestsBlocked": "Gästeinladungen sind blockiert. Bitte Paket upgraden oder Kontingent freigeben.",
|
||||
"galleryTitle": "Galerie",
|
||||
"galleryWarningDay": "Galerie läuft in {days} Tag ab.",
|
||||
"galleryWarningDays": "Galerie läuft in {days} Tagen ab.",
|
||||
"galleryExpired": "Galerie ist abgelaufen. Gäste sehen keine Inhalte mehr.",
|
||||
"unlimited": "Unbegrenzt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@
|
||||
"description": "Aktives Paket und Historie einsehen."
|
||||
}
|
||||
},
|
||||
"limitsCard": {
|
||||
"title": "Kontingente & Laufzeiten",
|
||||
"description": "Fokus-Event: {{name}}",
|
||||
"descriptionFallback": "Kein Fokus-Event ausgewählt",
|
||||
"photosLabel": "Foto-Uploads",
|
||||
"guestsLabel": "Gastzugänge",
|
||||
"galleryLabel": "Galerie-Laufzeit",
|
||||
"usageLabel": "{{used}} von {{limit}} genutzt",
|
||||
"remainingLabel": "{{remaining}} übrig (Limit {{limit}})",
|
||||
"galleryExpires": "Läuft am {{date}} ab",
|
||||
"galleryNoExpiry": "Keine Ablaufzeit hinterlegt"
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Kommende Events",
|
||||
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
||||
@@ -158,6 +170,18 @@
|
||||
"description": "Aktives Paket und Historie einsehen."
|
||||
}
|
||||
},
|
||||
"limitsCard": {
|
||||
"title": "Kontingente & Laufzeiten",
|
||||
"description": "Fokus-Event: {{name}}",
|
||||
"descriptionFallback": "Kein Fokus-Event ausgewählt",
|
||||
"photosLabel": "Foto-Uploads",
|
||||
"guestsLabel": "Gastzugänge",
|
||||
"galleryLabel": "Galerie-Laufzeit",
|
||||
"usageLabel": "{{used}} von {{limit}} genutzt",
|
||||
"remainingLabel": "{{remaining}} übrig (Limit {{limit}})",
|
||||
"galleryExpires": "Läuft am {{date}} ab",
|
||||
"galleryNoExpiry": "Keine Ablaufzeit hinterlegt"
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Kommende Events",
|
||||
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
"label": "Läuft ab",
|
||||
"helper": "Automatische Verlängerung, falls aktiv"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"noEvents": "Event-Kontingent aufgebraucht. Bitte Paket upgraden oder erneuern.",
|
||||
"lowEvents": "Nur noch {{remaining}} Event-Slots verfügbar.",
|
||||
"expiresSoon": "Paket läuft am {{date}} ab.",
|
||||
"expired": "Paket ist abgelaufen."
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
@@ -43,7 +49,13 @@
|
||||
"statusInactive": "Inaktiv",
|
||||
"used": "Genutzte Events",
|
||||
"available": "Verfügbar",
|
||||
"expires": "Läuft ab"
|
||||
"expires": "Läuft ab",
|
||||
"warnings": {
|
||||
"noEvents": "Event-Kontingent aufgebraucht.",
|
||||
"lowEvents": "Nur noch {{remaining}} Events verbleiben.",
|
||||
"expiresSoon": "Läuft am {{date}} ab.",
|
||||
"expired": "Paket ist abgelaufen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
@@ -81,6 +93,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"photos": {
|
||||
"moderation": {
|
||||
"title": "Fotos moderieren",
|
||||
"subtitle": "Setze Highlights oder entferne unpassende Uploads."
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Aktion fehlgeschlagen"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Galerie",
|
||||
"description": "Klick auf ein Foto, um es hervorzuheben oder zu löschen.",
|
||||
"emptyTitle": "Noch keine Fotos vorhanden",
|
||||
"emptyDescription": "Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie."
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"list": {
|
||||
"title": "Deine Events",
|
||||
"subtitle": "Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.",
|
||||
"actions": {
|
||||
"create": "Neues Event",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Übersicht",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"title": "Event-Mitglieder",
|
||||
"subtitle": "Verwalte Moderatoren, Admins und Helfer für dieses Event.",
|
||||
@@ -715,4 +760,68 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
,
|
||||
"settings": {
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Lege fest, für welche Ereignisse wir dich per E-Mail informieren.",
|
||||
"errorLoad": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
|
||||
"errorSave": "Speichern fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"hint": "Du kannst Benachrichtigungen jederzeit wieder aktivieren.",
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"reset": "Auf Standard setzen"
|
||||
},
|
||||
"meta": {
|
||||
"creditLast": "Letzte Credit-Warnung: {{date}}",
|
||||
"creditNever": "Noch keine Credit-Warnung versendet."
|
||||
},
|
||||
"items": {
|
||||
"photoThresholds": {
|
||||
"label": "Warnung bei Foto-Schwellen",
|
||||
"description": "Sende Warnungen bei 80 % und 95 % Foto-Auslastung."
|
||||
},
|
||||
"photoLimits": {
|
||||
"label": "Sperre bei Foto-Limit",
|
||||
"description": "Informiere mich, sobald keine Foto-Uploads mehr möglich sind."
|
||||
},
|
||||
"guestThresholds": {
|
||||
"label": "Warnung bei Gästekontingent",
|
||||
"description": "Warnung kurz bevor alle Gästelinks vergeben sind."
|
||||
},
|
||||
"guestLimits": {
|
||||
"label": "Sperre bei Gästelimit",
|
||||
"description": "Hinweis, wenn keine neuen Gästelinks mehr erzeugt werden können."
|
||||
},
|
||||
"galleryWarnings": {
|
||||
"label": "Galerie läuft bald ab",
|
||||
"description": "Erhalte 7 und 1 Tag vor Ablauf eine Erinnerung."
|
||||
},
|
||||
"galleryExpired": {
|
||||
"label": "Galerie ist abgelaufen",
|
||||
"description": "Informiere mich, sobald Gäste die Galerie nicht mehr sehen können."
|
||||
},
|
||||
"eventThresholds": {
|
||||
"label": "Warnung bei Event-Kontingent",
|
||||
"description": "Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist."
|
||||
},
|
||||
"eventLimits": {
|
||||
"label": "Sperre bei Event-Kontingent",
|
||||
"description": "Nachricht, sobald keine weiteren Events erstellt werden können."
|
||||
},
|
||||
"packageExpiring": {
|
||||
"label": "Paket läuft bald ab",
|
||||
"description": "Erinnerungen bei 30, 7 und 1 Tag vor Paketablauf."
|
||||
},
|
||||
"packageExpired": {
|
||||
"label": "Paket ist abgelaufen",
|
||||
"description": "Benachrichtige mich, wenn das Paket abgelaufen ist."
|
||||
},
|
||||
"creditsLow": {
|
||||
"label": "Event-Credits werden knapp",
|
||||
"description": "Informiert mich bei niedrigen Credit-Schwellen."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,18 @@
|
||||
"creditsExhausted": "You have no event credits remaining. Purchase credits or upgrade your package.",
|
||||
"photoLimit": "This event reached its photo upload limit.",
|
||||
"goToBilling": "Manage subscription"
|
||||
},
|
||||
"limits": {
|
||||
"photosTitle": "Photo limit",
|
||||
"photosWarning": "Only {remaining} of {limit} photo uploads remaining.",
|
||||
"photosBlocked": "Photo uploads are blocked. Please upgrade or extend your package.",
|
||||
"guestsTitle": "Guest limit",
|
||||
"guestsWarning": "Only {remaining} of {limit} guest invites remaining.",
|
||||
"guestsBlocked": "Guest invites are blocked. Please upgrade your package.",
|
||||
"galleryTitle": "Gallery",
|
||||
"galleryWarningDay": "Gallery expires in {days} day.",
|
||||
"galleryWarningDays": "Gallery expires in {days} days.",
|
||||
"galleryExpired": "Gallery has expired. Guests can no longer access the photos.",
|
||||
"unlimited": "Unlimited"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@
|
||||
"description": "View your active package and history."
|
||||
}
|
||||
},
|
||||
"limitsCard": {
|
||||
"title": "Limits & gallery status",
|
||||
"description": "Focus event: {{name}}",
|
||||
"descriptionFallback": "No focus event selected",
|
||||
"photosLabel": "Photo uploads",
|
||||
"guestsLabel": "Guest invites",
|
||||
"galleryLabel": "Gallery runtime",
|
||||
"usageLabel": "{{used}} of {{limit}} used",
|
||||
"remainingLabel": "{{remaining}} remaining (limit {{limit}})",
|
||||
"galleryExpires": "Expires on {{date}}",
|
||||
"galleryNoExpiry": "No expiry configured"
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Upcoming events",
|
||||
"description": "The next dates including status and quick access.",
|
||||
@@ -158,6 +170,18 @@
|
||||
"description": "View your active package and history."
|
||||
}
|
||||
},
|
||||
"limitsCard": {
|
||||
"title": "Limits & gallery status",
|
||||
"description": "Focus event: {{name}}",
|
||||
"descriptionFallback": "No focus event selected",
|
||||
"photosLabel": "Photo uploads",
|
||||
"guestsLabel": "Guest invites",
|
||||
"galleryLabel": "Gallery runtime",
|
||||
"usageLabel": "{{used}} of {{limit}} used",
|
||||
"remainingLabel": "{{remaining}} remaining (limit {{limit}})",
|
||||
"galleryExpires": "Expires on {{date}}",
|
||||
"galleryNoExpiry": "No expiry configured"
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Upcoming events",
|
||||
"description": "The next dates including status and quick access.",
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
"label": "Expires",
|
||||
"helper": "Auto-renews if enabled"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"noEvents": "Event allowance exhausted. Please upgrade or renew your package.",
|
||||
"lowEvents": "Only {{remaining}} event slots remaining.",
|
||||
"expiresSoon": "Package expires on {{date}}.",
|
||||
"expired": "Package has expired."
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
@@ -43,7 +49,13 @@
|
||||
"statusInactive": "Inactive",
|
||||
"used": "Events used",
|
||||
"available": "Remaining",
|
||||
"expires": "Expires"
|
||||
"expires": "Expires",
|
||||
"warnings": {
|
||||
"noEvents": "Event allowance exhausted.",
|
||||
"lowEvents": "Only {{remaining}} events left.",
|
||||
"expiresSoon": "Expires on {{date}}.",
|
||||
"expired": "Package has expired."
|
||||
}
|
||||
}
|
||||
},
|
||||
"transactions": {
|
||||
@@ -81,6 +93,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"photos": {
|
||||
"moderation": {
|
||||
"title": "Moderate photos",
|
||||
"subtitle": "Highlight favourites or remove unsuitable uploads."
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Action failed"
|
||||
},
|
||||
"gallery": {
|
||||
"title": "Gallery",
|
||||
"description": "Click a photo to feature it or remove it.",
|
||||
"emptyTitle": "No photos yet",
|
||||
"emptyDescription": "Encourage your guests to upload – the gallery will appear here."
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"list": {
|
||||
"title": "Your events",
|
||||
"subtitle": "Plan memorable moments. Manage everything around your events here.",
|
||||
"actions": {
|
||||
"create": "New event",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"empty": "No events yet – create your first one to get started.",
|
||||
"count": "{{count}} {{count, plural, one {event} other {events}}} managed.",
|
||||
"badge": {
|
||||
"dashboard": "Tenant dashboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"title": "Event members",
|
||||
"subtitle": "Manage moderators, admins, and helpers for this event.",
|
||||
@@ -715,4 +760,68 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
,
|
||||
"settings": {
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Choose which events should trigger an email notification.",
|
||||
"errorLoad": "Unable to load notification preferences.",
|
||||
"errorSave": "Saving failed. Please try again.",
|
||||
"hint": "You can re-enable notifications at any time.",
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"reset": "Reset to defaults"
|
||||
},
|
||||
"meta": {
|
||||
"creditLast": "Last credit warning: {{date}}",
|
||||
"creditNever": "No credit warning sent yet."
|
||||
},
|
||||
"items": {
|
||||
"photoThresholds": {
|
||||
"label": "Photo thresholds",
|
||||
"description": "Send warnings when photo usage reaches 80% and 95%."
|
||||
},
|
||||
"photoLimits": {
|
||||
"label": "Photo limit reached",
|
||||
"description": "Let me know when no further uploads are possible."
|
||||
},
|
||||
"guestThresholds": {
|
||||
"label": "Guest quota warning",
|
||||
"description": "Warn me shortly before all guest links are in use."
|
||||
},
|
||||
"guestLimits": {
|
||||
"label": "Guest quota exhausted",
|
||||
"description": "Inform me when no more guest links can be generated."
|
||||
},
|
||||
"galleryWarnings": {
|
||||
"label": "Gallery ends soon",
|
||||
"description": "Receive reminders 7 and 1 day before the gallery expires."
|
||||
},
|
||||
"galleryExpired": {
|
||||
"label": "Gallery expired",
|
||||
"description": "Let me know when guests can no longer access the gallery."
|
||||
},
|
||||
"eventThresholds": {
|
||||
"label": "Event quota warning",
|
||||
"description": "Notify me when the reseller package is almost used up."
|
||||
},
|
||||
"eventLimits": {
|
||||
"label": "Event quota exhausted",
|
||||
"description": "Notify me when no further events can be created."
|
||||
},
|
||||
"packageExpiring": {
|
||||
"label": "Package expires soon",
|
||||
"description": "Reminders 30, 7, and 1 day before the package expires."
|
||||
},
|
||||
"packageExpired": {
|
||||
"label": "Package expired",
|
||||
"description": "Inform me once the package has expired."
|
||||
},
|
||||
"creditsLow": {
|
||||
"label": "Event credits running low",
|
||||
"description": "Warn me when credit thresholds are reached."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,23 @@ export class ApiError extends Error {
|
||||
export function isApiError(value: unknown): value is ApiError {
|
||||
return value instanceof ApiError;
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error: unknown, fallback: string): string {
|
||||
if (isApiError(error)) {
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error.status && error.status >= 500) {
|
||||
return 'Der Server hat nicht reagiert. Bitte versuche es später erneut.';
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
118
resources/js/admin/lib/limitWarnings.ts
Normal file
118
resources/js/admin/lib/limitWarnings.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export type LimitWarningTone = 'warning' | 'danger';
|
||||
|
||||
export type LimitWarning = {
|
||||
id: string;
|
||||
scope: 'photos' | 'guests' | 'gallery';
|
||||
tone: LimitWarningTone;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type LimitUsageSummary = {
|
||||
limit: number | null;
|
||||
used: number;
|
||||
remaining: number | null;
|
||||
percentage: number | null;
|
||||
state: 'ok' | 'warning' | 'limit_reached' | 'unlimited';
|
||||
threshold_reached: number | null;
|
||||
next_threshold: number | null;
|
||||
thresholds: number[];
|
||||
} | null;
|
||||
|
||||
export type GallerySummary = {
|
||||
state: 'ok' | 'warning' | 'expired' | 'unlimited';
|
||||
expires_at: string | null;
|
||||
days_remaining: number | null;
|
||||
warning_thresholds: number[];
|
||||
warning_triggered: number | null;
|
||||
warning_sent_at: string | null;
|
||||
expired_notified_at: string | null;
|
||||
} | null;
|
||||
|
||||
export type EventLimitSummary = {
|
||||
photos: LimitUsageSummary;
|
||||
guests: LimitUsageSummary;
|
||||
gallery: GallerySummary;
|
||||
can_upload_photos: boolean;
|
||||
can_add_guests: boolean;
|
||||
} | null | undefined;
|
||||
|
||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function hasRemaining(summary: LimitUsageSummary): summary is LimitUsageSummary & { remaining: number; limit: number } {
|
||||
return Boolean(summary)
|
||||
&& typeof summary?.remaining === 'number'
|
||||
&& typeof summary?.limit === 'number';
|
||||
}
|
||||
|
||||
export function buildLimitWarnings(limits: EventLimitSummary, t: TranslateFn): LimitWarning[] {
|
||||
if (!limits) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const warnings: LimitWarning[] = [];
|
||||
|
||||
if (limits.photos) {
|
||||
if (limits.photos.state === 'limit_reached') {
|
||||
warnings.push({
|
||||
id: 'photos-limit',
|
||||
scope: 'photos',
|
||||
tone: 'danger',
|
||||
message: t('photosBlocked'),
|
||||
});
|
||||
} else if (limits.photos.state === 'warning' && hasRemaining(limits.photos)) {
|
||||
warnings.push({
|
||||
id: 'photos-warning',
|
||||
scope: 'photos',
|
||||
tone: 'warning',
|
||||
message: t('photosWarning', {
|
||||
remaining: limits.photos.remaining,
|
||||
limit: limits.photos.limit,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (limits.guests) {
|
||||
if (limits.guests.state === 'limit_reached') {
|
||||
warnings.push({
|
||||
id: 'guests-limit',
|
||||
scope: 'guests',
|
||||
tone: 'danger',
|
||||
message: t('guestsBlocked'),
|
||||
});
|
||||
} else if (limits.guests.state === 'warning' && hasRemaining(limits.guests)) {
|
||||
warnings.push({
|
||||
id: 'guests-warning',
|
||||
scope: 'guests',
|
||||
tone: 'warning',
|
||||
message: t('guestsWarning', {
|
||||
remaining: limits.guests.remaining,
|
||||
limit: limits.guests.limit,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (limits.gallery) {
|
||||
if (limits.gallery.state === 'expired') {
|
||||
warnings.push({
|
||||
id: 'gallery-expired',
|
||||
scope: 'gallery',
|
||||
tone: 'danger',
|
||||
message: t('galleryExpired'),
|
||||
});
|
||||
} else if (limits.gallery.state === 'warning') {
|
||||
const days = limits.gallery.days_remaining ?? 0;
|
||||
const safeDays = Math.max(0, days);
|
||||
const key = safeDays === 1 ? 'galleryWarningDay' : 'galleryWarningDays';
|
||||
warnings.push({
|
||||
id: 'gallery-warning',
|
||||
scope: 'gallery',
|
||||
tone: 'warning',
|
||||
message: t(key, { days: safeDays }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -12,6 +12,8 @@ import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
|
||||
|
||||
export default function BillingPage() {
|
||||
const { t, i18n } = useTranslation(['management', 'dashboard']);
|
||||
const locale = React.useMemo(
|
||||
@@ -112,6 +114,11 @@ export default function BillingPage() {
|
||||
</Button>
|
||||
);
|
||||
|
||||
const activeWarnings = React.useMemo(
|
||||
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
||||
[activePackage, t, formatDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('billing.title')}
|
||||
@@ -146,33 +153,52 @@ export default function BillingPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activePackage ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.package.label')}
|
||||
value={activePackage.package_name}
|
||||
tone="pink"
|
||||
helper={t('billing.sections.overview.cards.package.helper')}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.used.label')}
|
||||
value={activePackage.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage.remaining_events ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.price.label')}
|
||||
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
|
||||
tone="sky"
|
||||
helper={activePackage.currency ?? 'EUR'}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.expires.label')}
|
||||
value={formatDate(activePackage.expires_at)}
|
||||
tone="emerald"
|
||||
helper={t('billing.sections.overview.cards.expires.helper')}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{activeWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{activeWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.package.label')}
|
||||
value={activePackage.package_name}
|
||||
tone="pink"
|
||||
helper={t('billing.sections.overview.cards.package.helper')}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.used.label')}
|
||||
value={activePackage.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage.remaining_events ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.price.label')}
|
||||
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
|
||||
tone="sky"
|
||||
helper={activePackage.currency ?? 'EUR'}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.expires.label')}
|
||||
value={formatDate(activePackage.expires_at)}
|
||||
tone="emerald"
|
||||
helper={t('billing.sections.overview.cards.expires.helper')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message={t('billing.sections.overview.empty')} />
|
||||
@@ -194,16 +220,20 @@ export default function BillingPage() {
|
||||
{packages.length === 0 ? (
|
||||
<EmptyState message={t('billing.sections.packages.empty')} />
|
||||
) : (
|
||||
packages.map((pkg) => (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
isActive={Boolean(pkg.active)}
|
||||
labels={packageLabels}
|
||||
formatDate={formatDate}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
))
|
||||
packages.map((pkg) => {
|
||||
const warnings = buildPackageWarnings(pkg, t, formatDate, 'billing.sections.packages.card.warnings');
|
||||
return (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
isActive={Boolean(pkg.active)}
|
||||
labels={packageLabels}
|
||||
formatDate={formatDate}
|
||||
formatCurrency={formatCurrency}
|
||||
warnings={warnings}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -369,6 +399,7 @@ function PackageCard({
|
||||
labels,
|
||||
formatDate,
|
||||
formatCurrency,
|
||||
warnings = [],
|
||||
}: {
|
||||
pkg: TenantPackageSummary;
|
||||
isActive: boolean;
|
||||
@@ -381,9 +412,26 @@ function PackageCard({
|
||||
};
|
||||
formatDate: (value: string | null | undefined) => string;
|
||||
formatCurrency: (value: number | null | undefined, currency?: string) => string;
|
||||
warnings?: PackageWarning[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
|
||||
{warnings.length > 0 && (
|
||||
<div className="mb-3 space-y-2">
|
||||
{warnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-xs">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
|
||||
@@ -422,6 +470,60 @@ function EmptyState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildPackageWarnings(
|
||||
pkg: TenantPackageSummary | null | undefined,
|
||||
translate: (key: string, options?: Record<string, unknown>) => string,
|
||||
formatDate: (value: string | null | undefined) => string,
|
||||
keyPrefix: string,
|
||||
): PackageWarning[] {
|
||||
if (!pkg) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const warnings: PackageWarning[] = [];
|
||||
const remaining = typeof pkg.remaining_events === 'number' ? pkg.remaining_events : null;
|
||||
|
||||
if (remaining !== null) {
|
||||
if (remaining <= 0) {
|
||||
warnings.push({
|
||||
id: `${pkg.id}-no-events`,
|
||||
tone: 'danger',
|
||||
message: translate(`${keyPrefix}.noEvents`),
|
||||
});
|
||||
} else if (remaining <= 2) {
|
||||
warnings.push({
|
||||
id: `${pkg.id}-low-events`,
|
||||
tone: 'warning',
|
||||
message: translate(`${keyPrefix}.lowEvents`, { remaining }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = pkg.expires_at ? new Date(pkg.expires_at) : null;
|
||||
if (expiresAt && !Number.isNaN(expiresAt.getTime())) {
|
||||
const now = new Date();
|
||||
const diffMillis = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffMillis / (1000 * 60 * 60 * 24));
|
||||
const formatted = formatDate(pkg.expires_at);
|
||||
|
||||
if (diffDays < 0) {
|
||||
warnings.push({
|
||||
id: `${pkg.id}-expired`,
|
||||
tone: 'danger',
|
||||
message: translate(`${keyPrefix}.expired`, { date: formatted }),
|
||||
});
|
||||
} else if (diffDays <= 14) {
|
||||
warnings.push({
|
||||
id: `${pkg.id}-expires`,
|
||||
tone: 'warning',
|
||||
message: translate(`${keyPrefix}.expiresSoon`, { date: formatted }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function BillingSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CalendarDays,
|
||||
Camera,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Users,
|
||||
Plus,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
buildEngagementTabPath,
|
||||
} from '../constants';
|
||||
import { useOnboardingProgress } from '../onboarding';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary | null;
|
||||
@@ -189,6 +191,28 @@ export default function DashboardPage() {
|
||||
|
||||
const upcomingEvents = getUpcomingEvents(events);
|
||||
const publishedEvents = events.filter((event) => event.status === 'published');
|
||||
const primaryEvent = events[0] ?? null;
|
||||
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||||
const primaryEventLimits = primaryEvent?.limits ?? null;
|
||||
|
||||
const limitTranslate = React.useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => tc(`limits.${key}`, options),
|
||||
[tc],
|
||||
);
|
||||
|
||||
const limitWarnings = React.useMemo(
|
||||
() => buildLimitWarnings(primaryEventLimits, limitTranslate),
|
||||
[primaryEventLimits, limitTranslate],
|
||||
);
|
||||
|
||||
const limitScopeLabels = React.useMemo(
|
||||
() => ({
|
||||
photos: tc('limits.photosTitle'),
|
||||
guests: tc('limits.guestsTitle'),
|
||||
gallery: tc('limits.galleryTitle'),
|
||||
}),
|
||||
[tc],
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
@@ -295,6 +319,76 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{primaryEventLimits ? (
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<PackageIcon className="h-5 w-5 text-brand-rose" />
|
||||
{translate('limitsCard.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{primaryEventName
|
||||
? translate('limitsCard.description', { name: primaryEventName })
|
||||
: translate('limitsCard.descriptionFallback')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||||
{primaryEventName ?? translate('limitsCard.descriptionFallback')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{limitWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{limitScopeLabels[warning.scope]}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<LimitUsageRow
|
||||
label={translate('limitsCard.photosLabel')}
|
||||
summary={primaryEventLimits.photos}
|
||||
unlimitedLabel={tc('limits.unlimited')}
|
||||
usageLabel={translate('limitsCard.usageLabel')}
|
||||
remainingLabel={translate('limitsCard.remainingLabel')}
|
||||
/>
|
||||
<LimitUsageRow
|
||||
label={translate('limitsCard.guestsLabel')}
|
||||
summary={primaryEventLimits.guests}
|
||||
unlimitedLabel={tc('limits.unlimited')}
|
||||
usageLabel={translate('limitsCard.usageLabel')}
|
||||
remainingLabel={translate('limitsCard.remainingLabel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryStatusRow
|
||||
label={translate('limitsCard.galleryLabel')}
|
||||
summary={primaryEventLimits.gallery}
|
||||
locale={dateLocale}
|
||||
messages={{
|
||||
expired: tc('limits.galleryExpired'),
|
||||
noExpiry: translate('limitsCard.galleryNoExpiry'),
|
||||
expires: translate('limitsCard.galleryExpires'),
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
@@ -422,6 +516,27 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null, locale: string): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
|
||||
if (typeof name === 'string' && name.trim().length > 0) {
|
||||
return name;
|
||||
@@ -500,6 +615,109 @@ type ReadinessLabels = {
|
||||
};
|
||||
};
|
||||
|
||||
function LimitUsageRow({
|
||||
label,
|
||||
summary,
|
||||
unlimitedLabel,
|
||||
usageLabel,
|
||||
remainingLabel,
|
||||
}: {
|
||||
label: string;
|
||||
summary: LimitUsageSummary | null;
|
||||
unlimitedLabel: string;
|
||||
usageLabel: string;
|
||||
remainingLabel: string;
|
||||
}) {
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>{label}</span>
|
||||
<span className="text-xs text-slate-500">{unlimitedLabel}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const limit = typeof summary.limit === 'number' && summary.limit > 0 ? summary.limit : null;
|
||||
const percent = limit ? Math.min(100, Math.round((summary.used / limit) * 100)) : 0;
|
||||
const remaining = typeof summary.remaining === 'number' ? summary.remaining : null;
|
||||
|
||||
const barClass = summary.state === 'limit_reached'
|
||||
? 'bg-rose-500'
|
||||
: summary.state === 'warning'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-emerald-500';
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>{label}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{limit ? usageLabel.replace('{{used}}', `${summary.used}`).replace('{{limit}}', `${limit}`) : unlimitedLabel}
|
||||
</span>
|
||||
</div>
|
||||
{limit ? (
|
||||
<>
|
||||
<div className="mt-3 h-2 rounded-full bg-slate-200">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${barClass}`}
|
||||
style={{ width: `${Math.max(6, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{remaining !== null ? (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{remainingLabel
|
||||
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
|
||||
.replace('{{limit}}', `${limit}`)}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryStatusRow({
|
||||
label,
|
||||
summary,
|
||||
locale,
|
||||
messages,
|
||||
}: {
|
||||
label: string;
|
||||
summary: GallerySummary | null;
|
||||
locale: string;
|
||||
messages: { expired: string; noExpiry: string; expires: string };
|
||||
}) {
|
||||
const expiresAt = summary?.expires_at ? formatDate(summary.expires_at, locale) : null;
|
||||
|
||||
let statusLabel = messages.noExpiry;
|
||||
let badgeClass = 'bg-emerald-500/20 text-emerald-700';
|
||||
|
||||
if (summary?.state === 'expired') {
|
||||
statusLabel = messages.expired;
|
||||
badgeClass = 'bg-rose-500/20 text-rose-700';
|
||||
} else if (summary?.state === 'warning') {
|
||||
const days = Math.max(0, summary.days_remaining ?? 0);
|
||||
statusLabel = `${messages.expires.replace('{{date}}', expiresAt ?? '')} (${days}d)`;
|
||||
badgeClass = 'bg-amber-500/20 text-amber-700';
|
||||
} else if (summary?.state === 'ok' && expiresAt) {
|
||||
statusLabel = messages.expires.replace('{{date}}', expiresAt);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>{label}</span>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass}`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessCard({
|
||||
readiness,
|
||||
labels,
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
toggleEvent,
|
||||
submitTenantFeedback,
|
||||
} from '../api';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
@@ -69,6 +71,7 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
|
||||
const slug = slugParam ?? null;
|
||||
|
||||
@@ -97,7 +100,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, error: t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'), loading: false }));
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: getApiErrorMessage(error, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')),
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +113,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
setToolkit({ data: toolkitData, loading: false, error: null });
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setToolkit({ data: null, loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.') });
|
||||
setToolkit({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: getApiErrorMessage(error, t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.')),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [slug, t]);
|
||||
@@ -138,7 +149,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({ ...prev, busy: false, error: t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.') }));
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
busy: false,
|
||||
error: getApiErrorMessage(error, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')),
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, busy: false }));
|
||||
}
|
||||
@@ -196,6 +211,11 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
);
|
||||
}
|
||||
|
||||
const limitWarnings = React.useMemo(
|
||||
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
||||
[event?.limits, tCommon],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
||||
{error && (
|
||||
@@ -205,6 +225,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="mb-6 space-y-2">
|
||||
{limitWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolkit.error && (
|
||||
<Alert variant="default">
|
||||
<AlertTitle>{toolkit.error}</AlertTitle>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { isApiError } from '../lib/apiError';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
interface EventFormState {
|
||||
@@ -66,6 +67,7 @@ export default function EventFormPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' });
|
||||
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
|
||||
|
||||
const [form, setForm] = React.useState<EventFormState>({
|
||||
name: '',
|
||||
@@ -193,6 +195,20 @@ export default function EventFormPage() {
|
||||
|
||||
const loading = isEdit ? eventLoading : false;
|
||||
|
||||
const limitWarnings = React.useMemo(() => {
|
||||
if (!isEdit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return buildLimitWarnings(loadedEvent?.limits, tLimits);
|
||||
}, [isEdit, loadedEvent?.limits, tLimits]);
|
||||
|
||||
const limitScopeLabels = React.useMemo(() => ({
|
||||
photos: tLimits('photosTitle'),
|
||||
guests: tLimits('guestsTitle'),
|
||||
gallery: tLimits('galleryTitle'),
|
||||
}), [tLimits]);
|
||||
|
||||
function ensureSlugSuffix(): string {
|
||||
if (!slugSuffixRef.current) {
|
||||
slugSuffixRef.current = Math.random().toString(36).slice(2, 7);
|
||||
@@ -394,11 +410,11 @@ export default function EventFormPage() {
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Hinweis</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
{error.split('\n').map((line, index) => (
|
||||
<span key={index}>{line}</span>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Hinweis</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
{error.split('\n').map((line, index) => (
|
||||
<span key={index}>{line}</span>
|
||||
))}
|
||||
{showUpgradeHint && (
|
||||
<div>
|
||||
@@ -411,6 +427,26 @@ export default function EventFormPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="mb-6 space-y-2">
|
||||
{limitWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{limitScopeLabels[warning.scope]}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
} from '../constants';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
|
||||
import {
|
||||
@@ -159,6 +160,7 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
|
||||
|
||||
const [state, setState] = React.useState<PageState>({ event: null, invites: [], loading: true, error: null });
|
||||
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
||||
@@ -711,12 +713,46 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
</div>
|
||||
);
|
||||
|
||||
const limitWarnings = React.useMemo(
|
||||
() => buildLimitWarnings(state.event?.limits, tLimits),
|
||||
[state.event?.limits, tLimits]
|
||||
);
|
||||
|
||||
const limitScopeLabels = React.useMemo(
|
||||
() => ({
|
||||
photos: tLimits('photosTitle'),
|
||||
guests: tLimits('guestsTitle'),
|
||||
gallery: tLimits('galleryTitle'),
|
||||
}),
|
||||
[tLimits]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName}
|
||||
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
||||
actions={actions}
|
||||
>
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="mb-6 space-y-2">
|
||||
{limitWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{limitScopeLabels[warning.scope]}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
||||
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
@@ -1075,12 +1111,17 @@ export default function EventInvitesPage(): JSX.Element {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateInvite}
|
||||
disabled={creatingInvite}
|
||||
disabled={creatingInvite || state.event?.limits?.can_add_guests === false}
|
||||
className="bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90"
|
||||
>
|
||||
{creatingInvite ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Share2 className="mr-1 h-4 w-4" />}
|
||||
{t('invites.actions.create', 'Neue Einladung erstellen')}
|
||||
</Button>
|
||||
{!state.loading && state.event?.limits?.can_add_guests === false && (
|
||||
<p className="w-full text-xs text-amber-600">
|
||||
{tLimits('guestsBlocked')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Camera, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -9,6 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
||||
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
|
||||
|
||||
export default function EventPhotosPage() {
|
||||
@@ -16,11 +19,18 @@ export default function EventPhotosPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const slug = params.slug ?? searchParams.get('slug') ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const translateLimits = React.useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => tCommon(`limits.${key}`, options),
|
||||
[tCommon]
|
||||
);
|
||||
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busyId, setBusyId] = React.useState<number | null>(null);
|
||||
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
@@ -30,11 +40,12 @@ export default function EventPhotosPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getEventPhotos(slug);
|
||||
setPhotos(data);
|
||||
const result = await getEventPhotos(slug);
|
||||
setPhotos(result.photos);
|
||||
setLimits(result.limits ?? null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Fotos konnten nicht geladen werden.');
|
||||
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -55,7 +66,7 @@ export default function EventPhotosPage() {
|
||||
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Feature-Aktion fehlgeschlagen.');
|
||||
setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
@@ -70,7 +81,7 @@ export default function EventPhotosPage() {
|
||||
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Foto konnte nicht entfernt werden.');
|
||||
setError(getApiErrorMessage(err, 'Foto konnte nicht entfernt werden.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
@@ -104,31 +115,36 @@ export default function EventPhotosPage() {
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Fotos moderieren"
|
||||
subtitle="Setze Highlights oder entferne unpassende Uploads."
|
||||
title={t('photos.moderation.title', 'Fotos moderieren')}
|
||||
subtitle={t('photos.moderation.subtitle', 'Setze Highlights oder entferne unpassende Uploads.')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
|
||||
<AlertTitle>{t('photos.alerts.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<LimitWarningsBanner limits={limits} translate={translateLimits} />
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Camera className="h-5 w-5 text-sky-500" /> Galerie
|
||||
<Camera className="h-5 w-5 text-sky-500" /> {t('photos.gallery.title', 'Galerie')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Klick auf ein Foto, um es hervorzuheben oder zu löschen.
|
||||
{t('photos.gallery.description', 'Klick auf ein Foto, um es hervorzuheben oder zu löschen.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<GallerySkeleton />
|
||||
) : photos.length === 0 ? (
|
||||
<EmptyGallery />
|
||||
<EmptyGallery
|
||||
title={t('photos.gallery.emptyTitle', 'Noch keine Fotos vorhanden')}
|
||||
description={t('photos.gallery.emptyDescription', 'Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.')}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{photos.map((photo) => (
|
||||
@@ -178,6 +194,37 @@ export default function EventPhotosPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function LimitWarningsBanner({
|
||||
limits,
|
||||
translate,
|
||||
}: {
|
||||
limits: EventLimitSummary | null;
|
||||
translate: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||||
|
||||
if (!warnings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-2">
|
||||
{warnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GallerySkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
@@ -188,15 +235,14 @@ function GallerySkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyGallery() {
|
||||
function EmptyGallery({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-sky-200 bg-white/70 p-10 text-center">
|
||||
<div className="rounded-full bg-sky-100 p-3 text-sky-600 shadow-inner shadow-sky-200/80">
|
||||
<Camera className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Noch keine Fotos vorhanden</h3>
|
||||
<p className="text-sm text-slate-600">Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.</p>
|
||||
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
||||
<p className="text-sm text-slate-600">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
@@ -21,8 +22,12 @@ import {
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
} from '../constants';
|
||||
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function EventsPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const [rows, setRows] = React.useState<TenantEvent[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -34,7 +39,7 @@ export default function EventsPage() {
|
||||
setRows(await getEvents());
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Laden fehlgeschlagen. Bitte später erneut versuchen.');
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -48,11 +53,11 @@ export default function EventsPage() {
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={() => navigate(adminPath('/events/new'))}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Neues Event
|
||||
<Plus className="h-4 w-4" /> {t('events.list.actions.create', 'Neues Event')}
|
||||
</Button>
|
||||
<Link to={ADMIN_SETTINGS_PATH}>
|
||||
<Button variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<Settings className="h-4 w-4" /> Einstellungen
|
||||
<Settings className="h-4 w-4" /> {t('events.list.actions.settings', 'Einstellungen')}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
@@ -60,8 +65,8 @@ export default function EventsPage() {
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Deine Events"
|
||||
subtitle="Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen."
|
||||
title={t('events.list.title', 'Deine Events')}
|
||||
subtitle={t('events.list.subtitle', 'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
@@ -74,15 +79,15 @@ export default function EventsPage() {
|
||||
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-slate-900">Übersicht</CardTitle>
|
||||
<CardTitle className="text-xl font-semibold text-slate-900">{t('events.list.overview.title', 'Übersicht')}</CardTitle>
|
||||
<CardDescription className="text-slate-600">
|
||||
{rows.length === 0
|
||||
? 'Noch keine Events - starte jetzt und lege dein erstes Event an.'
|
||||
: `${rows.length} ${rows.length === 1 ? 'Event' : 'Events'} aktiv verwaltet.`}
|
||||
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
|
||||
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: rows.length })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-pink-600">
|
||||
<Sparkles className="h-4 w-4" /> Tenant Dashboard
|
||||
<Sparkles className="h-4 w-4" /> {t('events.list.badge.dashboard', 'Tenant Dashboard')}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -93,7 +98,7 @@ export default function EventsPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{rows.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
<EventCard key={event.id} event={event} translateCommon={tCommon} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -103,14 +108,41 @@ export default function EventsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function EventCard({ event }: { event: TenantEvent }) {
|
||||
function EventCard({
|
||||
event,
|
||||
translateCommon,
|
||||
}: {
|
||||
event: TenantEvent;
|
||||
translateCommon: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const slug = event.slug;
|
||||
const isPublished = event.status === 'published';
|
||||
const photoCount = event.photo_count ?? 0;
|
||||
const likeCount = event.like_count ?? 0;
|
||||
const limitWarnings = React.useMemo(
|
||||
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, opts)),
|
||||
[event.limits, translateCommon],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/80 bg-white/90 p-5 shadow-md shadow-pink-200/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{limitWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<AlertDescription className="flex items-center gap-2 text-xs">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
|
||||
|
||||
@@ -1,23 +1,56 @@
|
||||
import React from 'react';
|
||||
import { LogOut, Palette } from 'lucide-react';
|
||||
import { AlertTriangle, LogOut, Palette } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH } from '../constants';
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
updateNotificationPreferences,
|
||||
NotificationPreferences,
|
||||
NotificationPreferencesMeta,
|
||||
} from '../api';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [preferences, setPreferences] = React.useState<NotificationPreferences | null>(null);
|
||||
const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
|
||||
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
|
||||
const [savingNotifications, setSavingNotifications] = React.useState(false);
|
||||
const [notificationError, setNotificationError] = React.useState<string | null>(null);
|
||||
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | null>(null);
|
||||
|
||||
function handleLogout() {
|
||||
logout({ redirect: ADMIN_LOGIN_PATH });
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const result = await getNotificationPreferences();
|
||||
setPreferences(result.preferences);
|
||||
setDefaults(result.defaults);
|
||||
setNotificationMeta(result.meta ?? null);
|
||||
} catch (error) {
|
||||
setNotificationError(getApiErrorMessage(error, t('settings.notifications.errorLoad', 'Benachrichtigungseinstellungen konnten nicht geladen werden.')));
|
||||
} finally {
|
||||
setLoadingNotifications(false);
|
||||
}
|
||||
})();
|
||||
}, [t]);
|
||||
|
||||
const actions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -75,7 +108,225 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-8 max-w-3xl border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<AlertTriangle className="h-5 w-5 text-pink-500" />
|
||||
{t('settings.notifications.title', 'Benachrichtigungen')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('settings.notifications.description', 'Lege fest, für welche Ereignisse wir dich per E-Mail informieren.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{notificationError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{notificationError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loadingNotifications ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-12 animate-pulse rounded-xl bg-gradient-to-r from-white/30 via-white/60 to-white/30"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : preferences ? (
|
||||
<NotificationPreferencesForm
|
||||
preferences={preferences}
|
||||
defaults={defaults}
|
||||
meta={notificationMeta}
|
||||
onChange={(next) => setPreferences(next)}
|
||||
onReset={() => setPreferences(defaults)}
|
||||
onSave={async () => {
|
||||
if (!preferences) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSavingNotifications(true);
|
||||
const updated = await updateNotificationPreferences(preferences);
|
||||
setPreferences(updated.preferences);
|
||||
if (updated.defaults && Object.keys(updated.defaults).length > 0) {
|
||||
setDefaults(updated.defaults);
|
||||
}
|
||||
if (updated.meta) {
|
||||
setNotificationMeta(updated.meta);
|
||||
}
|
||||
setNotificationError(null);
|
||||
} catch (error) {
|
||||
setNotificationError(
|
||||
getApiErrorMessage(error, t('settings.notifications.errorSave', 'Speichern fehlgeschlagen. Bitte versuche es erneut.')),
|
||||
);
|
||||
} finally {
|
||||
setSavingNotifications(false);
|
||||
}
|
||||
}}
|
||||
saving={savingNotifications}
|
||||
translate={t}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationPreferencesForm({
|
||||
preferences,
|
||||
defaults,
|
||||
meta,
|
||||
onChange,
|
||||
onReset,
|
||||
onSave,
|
||||
saving,
|
||||
translate,
|
||||
}: {
|
||||
preferences: NotificationPreferences;
|
||||
defaults: NotificationPreferences;
|
||||
meta: NotificationPreferencesMeta | null;
|
||||
onChange: (next: NotificationPreferences) => void;
|
||||
onReset: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
saving: boolean;
|
||||
translate: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const items = React.useMemo(() => buildPreferenceMeta(translate), [translate]);
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
||||
const creditText = React.useMemo(() => {
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (meta.credit_warning_sent_at) {
|
||||
const date = formatDateTime(meta.credit_warning_sent_at, locale);
|
||||
|
||||
return translate('settings.notifications.meta.creditLast', 'Letzte Credit-Warnung: {{date}}', {
|
||||
date,
|
||||
});
|
||||
}
|
||||
|
||||
return translate('settings.notifications.meta.creditNever', 'Noch keine Credit-Warnung versendet.');
|
||||
}, [meta, translate, locale]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const checked = preferences[item.key] ?? defaults[item.key] ?? true;
|
||||
|
||||
return (
|
||||
<div key={item.key} className="flex items-start justify-between gap-4 rounded-xl border border-pink-100 bg-white/70 p-4 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-slate-900">{item.label}</h3>
|
||||
<p className="text-sm text-slate-600">{item.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => onChange({ ...preferences, [item.key]: Boolean(value) })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={onSave} disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
{saving ? 'Speichern...' : translate('settings.notifications.actions.save', 'Speichern')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onReset} disabled={saving}>
|
||||
{translate('settings.notifications.actions.reset', 'Auf Standard setzen')}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
{translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
|
||||
</span>
|
||||
</div>
|
||||
{creditText && <p className="text-xs text-slate-500">{creditText}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPreferenceMeta(
|
||||
translate: (key: string, options?: Record<string, unknown>) => string
|
||||
): Array<{ key: keyof NotificationPreferences; label: string; description: string }> {
|
||||
const map = [
|
||||
{
|
||||
key: 'photo_thresholds',
|
||||
label: translate('settings.notifications.items.photoThresholds.label', 'Warnung bei Foto-Schwellen'),
|
||||
description: translate('settings.notifications.items.photoThresholds.description', 'Sende Warnungen bei 80 % und 95 % Foto-Auslastung.'),
|
||||
},
|
||||
{
|
||||
key: 'photo_limits',
|
||||
label: translate('settings.notifications.items.photoLimits.label', 'Sperre bei Foto-Limit'),
|
||||
description: translate('settings.notifications.items.photoLimits.description', 'Informiere mich, sobald keine Foto-Uploads mehr möglich sind.'),
|
||||
},
|
||||
{
|
||||
key: 'guest_thresholds',
|
||||
label: translate('settings.notifications.items.guestThresholds.label', 'Warnung bei Gästekontingent'),
|
||||
description: translate('settings.notifications.items.guestThresholds.description', 'Warnung kurz bevor alle Gästelinks vergeben sind.'),
|
||||
},
|
||||
{
|
||||
key: 'guest_limits',
|
||||
label: translate('settings.notifications.items.guestLimits.label', 'Sperre bei Gästelimit'),
|
||||
description: translate('settings.notifications.items.guestLimits.description', 'Hinweis, wenn keine neuen Gästelinks mehr erzeugt werden können.'),
|
||||
},
|
||||
{
|
||||
key: 'gallery_warnings',
|
||||
label: translate('settings.notifications.items.galleryWarnings.label', 'Galerie läuft bald ab'),
|
||||
description: translate('settings.notifications.items.galleryWarnings.description', 'Erhalte 7 und 1 Tag vor Ablauf eine Erinnerung.'),
|
||||
},
|
||||
{
|
||||
key: 'gallery_expired',
|
||||
label: translate('settings.notifications.items.galleryExpired.label', 'Galerie ist abgelaufen'),
|
||||
description: translate('settings.notifications.items.galleryExpired.description', 'Informiere mich, sobald Gäste die Galerie nicht mehr sehen können.'),
|
||||
},
|
||||
{
|
||||
key: 'event_thresholds',
|
||||
label: translate('settings.notifications.items.eventThresholds.label', 'Warnung bei Event-Kontingent'),
|
||||
description: translate('settings.notifications.items.eventThresholds.description', 'Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist.'),
|
||||
},
|
||||
{
|
||||
key: 'event_limits',
|
||||
label: translate('settings.notifications.items.eventLimits.label', 'Sperre bei Event-Kontingent'),
|
||||
description: translate('settings.notifications.items.eventLimits.description', 'Nachricht, sobald keine weiteren Events erstellt werden können.'),
|
||||
},
|
||||
{
|
||||
key: 'package_expiring',
|
||||
label: translate('settings.notifications.items.packageExpiring.label', 'Paket läuft bald ab'),
|
||||
description: translate('settings.notifications.items.packageExpiring.description', 'Erinnerungen bei 30, 7 und 1 Tag vor Paketablauf.'),
|
||||
},
|
||||
{
|
||||
key: 'package_expired',
|
||||
label: translate('settings.notifications.items.packageExpired.label', 'Paket ist abgelaufen'),
|
||||
description: translate('settings.notifications.items.packageExpired.description', 'Benachrichtige mich, wenn das Paket abgelaufen ist.'),
|
||||
},
|
||||
{
|
||||
key: 'credits_low',
|
||||
label: translate('settings.notifications.items.creditsLow.label', 'Event-Credits werden knapp'),
|
||||
description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Credit-Schwellen.'),
|
||||
},
|
||||
];
|
||||
|
||||
return map as Array<{ key: keyof NotificationPreferences; label: string; description: string }>;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string, locale: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user