Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).

Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
Codex Agent
2025-11-01 19:50:17 +01:00
parent 2c14493604
commit 79b209de9a
55 changed files with 3348 additions and 462 deletions

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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.",

View File

@@ -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."
}
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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.",

View File

@@ -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."
}
}
}
}
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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">

View File

@@ -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,

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -188,6 +188,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Nicht gefunden',
description: 'Die Seite konnte nicht gefunden werden.',
},
galleryCountdown: {
expiresIn: 'Noch {days} Tage online',
expiresToday: 'Letzter Tag!',
expired: 'Galerie abgelaufen',
description: 'Sichere jetzt deine Lieblingsfotos die Galerie verschwindet bald.',
expiredDescription: 'Nur die Veranstalter:innen können die Galerie jetzt noch verlängern.',
ctaUpload: 'Letzte Fotos hochladen',
},
galleryPublic: {
title: 'Galerie',
loading: 'Galerie wird geladen ...',
@@ -298,6 +306,63 @@ export const messages: Record<LocaleCode, NestedMessages> = {
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
limitUnlimited: 'unbegrenzt',
limitWarning: 'Nur noch {remaining} von {max} Fotos möglich. Bitte kontaktiere die Veranstalter für ein Upgrade.',
galleryWarningDay: 'Galerie läuft in {days} Tag ab. Teile deine Fotos rechtzeitig!',
galleryWarningDays: 'Galerie läuft in {days} Tagen ab. Teile deine Fotos rechtzeitig!',
status: {
title: 'Dein Paketstatus',
subtitle: 'Behalte deine Kontingente im Blick, bevor es eng wird.',
badges: {
ok: 'Bereit',
warning: 'Hinweis',
limit_reached: 'Limit erreicht',
unlimited: 'Unbegrenzt',
},
cards: {
photos: {
title: 'Fotos',
remaining: 'Nur noch {remaining} von {limit} Fotos möglich',
unlimited: 'Unbegrenzte Foto-Uploads inklusive',
},
guests: {
title: 'Gäste',
remaining: '{remaining} Gäste frei (max. {limit})',
unlimited: 'Beliebig viele Gäste erlaubt',
},
},
},
dialogs: {
close: 'Verstanden',
photoLimit: {
title: 'Upload-Limit erreicht',
description: 'Es wurden {used} von {limit} Fotos hochgeladen. Bitte kontaktiere das Team für ein Upgrade.',
hint: 'Tipp: Größere Pakete schalten sofort wieder Uploads frei.',
},
deviceLimit: {
title: 'Gerätelimit erreicht',
description: 'Dieses Gerät hat sein Upload-Kontingent ausgeschöpft.',
hint: 'Nutze ein anderes Gerät oder sprich die Veranstalter:innen an.',
},
packageMissing: {
title: 'Event pausiert Uploads',
description: 'Für dieses Event sind aktuell keine Uploads freigeschaltet.',
hint: 'Bitte kontaktiere die Veranstalter:innen für weitere Informationen.',
},
galleryExpired: {
title: 'Galerie abgelaufen',
description: 'Die Galerie ist geschlossen neue Uploads sind nicht mehr möglich.',
hint: 'Nur die Veranstalter:innen können die Galerie verlängern.',
},
csrf: {
title: 'Sitzung abgelaufen',
description: 'Bitte lade die Seite neu und versuche den Upload anschließend erneut.',
hint: 'Aktualisiere die Seite, um eine neue Sitzung zu starten.',
},
generic: {
title: 'Upload fehlgeschlagen',
description: 'Der Upload konnte nicht abgeschlossen werden. Bitte versuche es später erneut.',
hint: 'Bleibt das Problem bestehen, kontaktiere die Veranstalter:innen.',
},
},
errors: {
photoLimit: 'Upload-Limit erreicht. Bitte kontaktiere die Veranstalter für ein Upgrade.',
deviceLimit: 'Dieses Gerät hat das Upload-Limit erreicht. Bitte wende dich an die Veranstalter.',
@@ -551,6 +616,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
title: 'Not found',
description: 'We could not find the page you requested.',
},
galleryCountdown: {
expiresIn: '{days} days remaining',
expiresToday: 'Final day!',
expired: 'Gallery expired',
description: 'Save your favourite photos before the gallery goes offline.',
expiredDescription: 'Only the organizers can extend the gallery now.',
ctaUpload: 'Share last photos',
},
galleryPublic: {
title: 'Gallery',
loading: 'Loading gallery ...',
@@ -661,6 +734,63 @@ export const messages: Record<LocaleCode, NestedMessages> = {
limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
limitUnlimited: 'unlimited',
limitWarning: 'Only {remaining} of {max} photos left. Please contact the organizers for an upgrade.',
galleryWarningDay: 'Gallery expires in {days} day. Upload your photos soon!',
galleryWarningDays: 'Gallery expires in {days} days. Upload your photos soon!',
status: {
title: 'Your package status',
subtitle: 'Keep an eye on your remaining allowances.',
badges: {
ok: 'Ready',
warning: 'Heads-up',
limit_reached: 'Limit reached',
unlimited: 'Unlimited',
},
cards: {
photos: {
title: 'Photos',
remaining: '{remaining} of {limit} photo slots left',
unlimited: 'Unlimited photo uploads included',
},
guests: {
title: 'Guests',
remaining: '{remaining} guest slots free (max {limit})',
unlimited: 'Unlimited guests allowed',
},
},
},
dialogs: {
close: 'Got it',
photoLimit: {
title: 'Upload limit reached',
description: '{used} of {limit} photos are already uploaded. Please reach out to upgrade your package.',
hint: 'Tip: Upgrading the package re-enables uploads instantly.',
},
deviceLimit: {
title: 'Device limit reached',
description: 'This device has used all available upload slots.',
hint: 'Try a different device or contact the organizers.',
},
packageMissing: {
title: 'Uploads paused',
description: 'This event is currently not accepting new uploads.',
hint: 'Check in with the organizers for the latest status.',
},
galleryExpired: {
title: 'Gallery closed',
description: 'The gallery has expired and no new uploads can be added.',
hint: 'Only the organizers can extend the gallery window.',
},
csrf: {
title: 'Session expired',
description: 'Refresh the page and try the upload again.',
hint: 'Reload the page to start a new session.',
},
generic: {
title: 'Upload failed',
description: 'We could not complete the upload. Please try again later.',
hint: 'If it keeps happening, reach out to the organizers.',
},
},
errors: {
photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.',
deviceLimit: 'This device reached its upload limit. Please contact the organizers.',

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import type { EventPackageLimits } from '../../services/eventApi';
import { buildLimitSummaries } from '../limitSummaries';
const translations = new Map<string, string>([
['upload.status.cards.photos.title', 'Fotos'],
['upload.status.cards.photos.remaining', 'Noch {remaining} von {limit}'],
['upload.status.cards.photos.unlimited', 'Unbegrenzte Uploads'],
['upload.status.cards.guests.title', 'Gäste'],
['upload.status.cards.guests.remaining', '{remaining} Gäste frei (max. {limit})'],
['upload.status.cards.guests.unlimited', 'Unbegrenzte Gäste'],
['upload.status.badges.ok', 'OK'],
['upload.status.badges.warning', 'Warnung'],
['upload.status.badges.limit_reached', 'Limit erreicht'],
['upload.status.badges.unlimited', 'Unbegrenzt'],
]);
const t = (key: string) => translations.get(key) ?? key;
describe('buildLimitSummaries', () => {
it('builds photo summary with progress and warning tone', () => {
const limits: EventPackageLimits = {
photos: {
limit: 100,
used: 80,
remaining: 20,
percentage: 80,
state: 'warning',
threshold_reached: 80,
next_threshold: 95,
thresholds: [80, 95],
},
guests: null,
gallery: null,
can_upload_photos: true,
can_add_guests: true,
};
const cards = buildLimitSummaries(limits, t);
expect(cards).toHaveLength(1);
const card = cards[0];
expect(card.id).toBe('photos');
expect(card.tone).toBe('warning');
expect(card.progress).toBe(80);
expect(card.valueLabel).toBe('80 / 100');
expect(card.description).toBe('Noch 20 von 100');
expect(card.badgeLabel).toBe('Warnung');
});
it('builds unlimited guest summary without progress', () => {
const limits: EventPackageLimits = {
photos: null,
guests: {
limit: null,
used: 5,
remaining: null,
percentage: null,
state: 'unlimited',
threshold_reached: null,
next_threshold: null,
thresholds: [],
},
gallery: null,
can_upload_photos: true,
can_add_guests: true,
};
const cards = buildLimitSummaries(limits, t);
expect(cards).toHaveLength(1);
const card = cards[0];
expect(card.id).toBe('guests');
expect(card.progress).toBeNull();
expect(card.tone).toBe('neutral');
expect(card.valueLabel).toBe('Unbegrenzt');
expect(card.description).toBe('Unbegrenzte Gäste');
expect(card.badgeLabel).toBe('Unbegrenzt');
});
it('returns empty list when no limits are provided', () => {
expect(buildLimitSummaries(null, t)).toEqual([]);
expect(buildLimitSummaries(undefined, t)).toEqual([]);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { resolveUploadErrorDialog } from '../uploadErrorDialog';
const translations = new Map<string, string>([
['upload.dialogs.photoLimit.title', 'Upload-Limit erreicht'],
['upload.dialogs.photoLimit.description', 'Es wurden {used} von {limit} Fotos hochgeladen. Es bleiben {remaining}.'],
['upload.dialogs.photoLimit.hint', 'Wende dich an das Team.'],
['upload.dialogs.deviceLimit.title', 'Dieses Gerät ist voll'],
['upload.dialogs.deviceLimit.description', 'Du hast das Geräte-Limit erreicht.'],
['upload.dialogs.deviceLimit.hint', 'Nutze ein anderes Gerät oder kontaktiere das Team.'],
['upload.dialogs.packageMissing.title', 'Event nicht bereit'],
['upload.dialogs.packageMissing.description', 'Das Event akzeptiert aktuell keine Uploads.'],
['upload.dialogs.packageMissing.hint', 'Frag die Veranstalter:innen nach dem Status.'],
['upload.dialogs.galleryExpired.title', 'Galerie abgelaufen'],
['upload.dialogs.galleryExpired.description', 'Uploads sind nicht mehr möglich.'],
['upload.dialogs.galleryExpired.hint', 'Bitte wende dich an die Veranstalter:innen.'],
['upload.dialogs.csrf.title', 'Sicherheitsabgleich erforderlich'],
['upload.dialogs.csrf.description', 'Bitte lade die Seite neu und versuche es erneut.'],
['upload.dialogs.csrf.hint', 'Aktualisiere die Seite.'],
['upload.dialogs.generic.title', 'Upload fehlgeschlagen'],
['upload.dialogs.generic.description', 'Der Upload konnte nicht abgeschlossen werden.'],
['upload.dialogs.generic.hint', 'Versuche es später erneut.'],
]);
const t = (key: string) => translations.get(key) ?? key;
describe('resolveUploadErrorDialog', () => {
it('renders photo limit dialog with placeholders', () => {
const dialog = resolveUploadErrorDialog(
'photo_limit_exceeded',
{ used: 120, limit: 120, remaining: 0 },
t
);
expect(dialog.title).toBe('Upload-Limit erreicht');
expect(dialog.description).toBe('Es wurden 120 von 120 Fotos hochgeladen. Es bleiben 0.');
expect(dialog.hint).toBe('Wende dich an das Team.');
expect(dialog.tone).toBe('danger');
});
it('falls back to generic dialog when code is unknown', () => {
const dialog = resolveUploadErrorDialog('something_else', undefined, t);
expect(dialog.tone).toBe('info');
expect(dialog.title).toBe('Upload fehlgeschlagen');
expect(dialog.description).toBe('Der Upload konnte nicht abgeschlossen werden.');
});
});

View File

@@ -0,0 +1,107 @@
import type { EventPackageLimits, LimitUsageSummary } from '../services/eventApi';
export type LimitTone = 'neutral' | 'warning' | 'danger';
export type LimitSummaryCard = {
id: 'photos' | 'guests';
label: string;
state: LimitUsageSummary['state'];
tone: LimitTone;
used: number;
limit: number | null;
remaining: number | null;
progress: number | null;
valueLabel: string;
description: string;
badgeLabel: string;
};
type TranslateFn = (key: string, fallback?: string) => string;
function resolveTone(state: LimitUsageSummary['state']): LimitTone {
if (state === 'limit_reached') {
return 'danger';
}
if (state === 'warning') {
return 'warning';
}
return 'neutral';
}
function buildCard(
id: 'photos' | 'guests',
summary: LimitUsageSummary,
t: TranslateFn
): LimitSummaryCard {
const labelKey = id === 'photos' ? 'upload.status.cards.photos.title' : 'upload.status.cards.guests.title';
const remainingKey = id === 'photos'
? 'upload.status.cards.photos.remaining'
: 'upload.status.cards.guests.remaining';
const unlimitedKey = id === 'photos'
? 'upload.status.cards.photos.unlimited'
: 'upload.status.cards.guests.unlimited';
const tone = resolveTone(summary.state);
const progress = typeof summary.limit === 'number' && summary.limit > 0
? Math.min(100, Math.round((summary.used / summary.limit) * 100))
: null;
const valueLabel = typeof summary.limit === 'number' && summary.limit > 0
? `${summary.used.toLocaleString()} / ${summary.limit.toLocaleString()}`
: t('upload.status.badges.unlimited');
const description = summary.state === 'unlimited'
? t(unlimitedKey)
: summary.remaining !== null && summary.limit !== null
? t(remainingKey)
.replace('{remaining}', `${Math.max(0, summary.remaining)}`)
.replace('{limit}', `${summary.limit}`)
: valueLabel;
const badgeKey = (() => {
switch (summary.state) {
case 'limit_reached':
return 'upload.status.badges.limit_reached';
case 'warning':
return 'upload.status.badges.warning';
case 'unlimited':
return 'upload.status.badges.unlimited';
default:
return 'upload.status.badges.ok';
}
})();
return {
id,
label: t(labelKey),
state: summary.state,
tone,
used: summary.used,
limit: summary.limit,
remaining: summary.remaining,
progress,
valueLabel,
description,
badgeLabel: t(badgeKey),
};
}
export function buildLimitSummaries(limits: EventPackageLimits | null | undefined, t: TranslateFn): LimitSummaryCard[] {
if (!limits) {
return [];
}
const cards: LimitSummaryCard[] = [];
if (limits.photos) {
cards.push(buildCard('photos', limits.photos, t));
}
if (limits.guests) {
cards.push(buildCard('guests', limits.guests, t));
}
return cards;
}

View File

@@ -0,0 +1,94 @@
import type { TranslateFn } from '../i18n/useTranslation';
export type UploadErrorDialog = {
code: string;
title: string;
description: string;
hint?: string;
tone: 'danger' | 'warning' | 'info';
};
function formatWithNumbers(template: string, values: Record<string, number | string | undefined>): string {
return Object.entries(values).reduce((acc, [key, value]) => {
if (value === undefined) {
return acc;
}
return acc.replaceAll(`{${key}}`, String(value));
}, template);
}
export function resolveUploadErrorDialog(
code: string | undefined,
meta: Record<string, unknown> | undefined,
t: TranslateFn
): UploadErrorDialog {
const normalized = (code ?? 'unknown').toLowerCase();
const getNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
switch (normalized) {
case 'photo_limit_exceeded': {
const used = getNumber(meta?.used);
const limit = getNumber(meta?.limit);
const remaining = getNumber(meta?.remaining);
return {
code: normalized,
tone: 'danger',
title: t('upload.dialogs.photoLimit.title'),
description: formatWithNumbers(t('upload.dialogs.photoLimit.description'), {
used,
limit,
remaining,
}),
hint: t('upload.dialogs.photoLimit.hint'),
};
}
case 'upload_device_limit':
return {
code: normalized,
tone: 'warning',
title: t('upload.dialogs.deviceLimit.title'),
description: t('upload.dialogs.deviceLimit.description'),
hint: t('upload.dialogs.deviceLimit.hint'),
};
case 'event_package_missing':
case 'event_not_found':
return {
code: normalized,
tone: 'info',
title: t('upload.dialogs.packageMissing.title'),
description: t('upload.dialogs.packageMissing.description'),
hint: t('upload.dialogs.packageMissing.hint'),
};
case 'gallery_expired':
return {
code: normalized,
tone: 'danger',
title: t('upload.dialogs.galleryExpired.title'),
description: t('upload.dialogs.galleryExpired.description'),
hint: t('upload.dialogs.galleryExpired.hint'),
};
case 'csrf_mismatch':
return {
code: normalized,
tone: 'warning',
title: t('upload.dialogs.csrf.title'),
description: t('upload.dialogs.csrf.description'),
hint: t('upload.dialogs.csrf.hint'),
};
default:
return {
code: normalized,
tone: 'info',
title: t('upload.dialogs.generic.title'),
description: t('upload.dialogs.generic.description'),
hint: t('upload.dialogs.generic.hint'),
};
}
}

View File

@@ -1,18 +1,21 @@
import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useParams, useSearchParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon } from 'lucide-react';
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon, AlertTriangle } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
import PhotoLightbox from './PhotoLightbox';
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
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 [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
@@ -22,6 +25,8 @@ export default function GalleryPage() {
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [stats, setStats] = useState<EventStats | null>(null);
const [eventLoading, setEventLoading] = useState(true);
const { t } = useTranslation();
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
const [searchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId');
@@ -82,6 +87,109 @@ export default function GalleryPage() {
const [liked, setLiked] = React.useState<Set<number>>(new Set());
const [counts, setCounts] = React.useState<Record<number, number>>({});
const photoLimits = eventPackage?.limits?.photos ?? null;
const guestLimits = eventPackage?.limits?.guests ?? null;
const galleryLimits = eventPackage?.limits?.gallery ?? null;
const galleryCountdown = React.useMemo(() => {
if (!galleryLimits) {
return null;
}
if (galleryLimits.state === 'expired') {
return {
tone: 'danger' as const,
label: t('galleryCountdown.expired'),
description: t('galleryCountdown.expiredDescription'),
cta: null,
};
}
if (galleryLimits.state === 'warning') {
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
const label = days <= 1
? t('galleryCountdown.expiresToday')
: t('galleryCountdown.expiresIn').replace('{days}', `${days}`);
return {
tone: days <= 1 ? ('danger' as const) : ('warning' as const),
label,
description: t('galleryCountdown.description'),
cta: {
type: 'upload' as const,
label: t('galleryCountdown.ctaUpload'),
},
};
}
return null;
}, [galleryLimits, t]);
const handleCountdownCta = React.useCallback(() => {
if (!galleryCountdown?.cta || !token) {
return;
}
if (galleryCountdown.cta.type === 'upload') {
navigate(`/e/${encodeURIComponent(token)}/upload`);
}
}, [galleryCountdown?.cta, navigate, token]);
const packageWarnings = React.useMemo(() => {
const warnings: { id: string; tone: 'warning' | 'danger'; message: string }[] = [];
if (photoLimits?.state === 'limit_reached' && typeof photoLimits.limit === 'number') {
warnings.push({
id: 'photos-blocked',
tone: 'danger',
message: t('upload.limitReached')
.replace('{used}', `${photoLimits.used}`)
.replace('{max}', `${photoLimits.limit}`),
});
} else if (
photoLimits?.state === 'warning'
&& typeof photoLimits.remaining === 'number'
&& typeof photoLimits.limit === 'number'
) {
warnings.push({
id: 'photos-warning',
tone: 'warning',
message: t('upload.limitWarning')
.replace('{remaining}', `${photoLimits.remaining}`)
.replace('{max}', `${photoLimits.limit}`),
});
}
if (galleryLimits?.state === 'expired') {
warnings.push({
id: 'gallery-expired',
tone: 'danger',
message: t('upload.errors.galleryExpired'),
});
} else if (galleryLimits?.state === 'warning') {
const days = Math.max(0, galleryLimits.days_remaining ?? 0);
const key = days === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays';
warnings.push({
id: 'gallery-warning',
tone: 'warning',
message: t(key).replace('{days}', `${days}`),
});
}
return warnings;
}, [photoLimits, galleryLimits, t]);
const formatDate = React.useCallback((value: 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);
}
}, [locale]);
async function onLike(id: number) {
if (liked.has(id)) return;
setLiked(new Set(liked).add(id));
@@ -111,17 +219,62 @@ export default function GalleryPage() {
<Page title="Galerie">
<Card className="mx-4 mb-4">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-6 w-6" />
Galerie: {event?.name || 'Event'}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
<p className="font-semibold">Online Gäste</p>
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex flex-wrap items-center gap-2">
<ImageIcon className="h-6 w-6" />
<span>Galerie: {event?.name || 'Event'}</span>
{galleryCountdown && (
<Badge
variant="secondary"
className={galleryCountdown.tone === 'danger'
? 'border-rose-200 bg-rose-100 text-rose-700'
: 'border-amber-200 bg-amber-100 text-amber-700'}
>
{galleryCountdown.label}
</Badge>
)}
</CardTitle>
{galleryCountdown?.cta && (
<Button
size="sm"
variant={galleryCountdown.tone === 'danger' ? 'destructive' : 'outline'}
onClick={handleCountdownCta}
disabled={!token}
>
{galleryCountdown.cta.label}
</Button>
)}
</div>
{galleryCountdown && (
<CardDescription className={galleryCountdown.tone === 'danger' ? 'text-rose-600' : 'text-amber-600'}>
{galleryCountdown.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{packageWarnings.length > 0 && (
<div className="space-y-2">
{packageWarnings.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 grid-cols-2 gap-4 md:grid-cols-4">
<div className="text-center">
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
<p className="font-semibold">Online Gäste</p>
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
</div>
<div className="text-center">
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
<p className="font-semibold">Gesamt Likes</p>
@@ -133,24 +286,38 @@ export default function GalleryPage() {
<p className="text-2xl">{photos.length}</p>
</div>
{eventPackage && (
<div className="text-center">
<PackageIcon className="h-8 w-8 mx-auto mb-2 text-purple-500" />
<div className="rounded-2xl border border-gray-200 bg-white/70 p-4 text-center">
<PackageIcon className="mx-auto mb-2 h-8 w-8 text-purple-500" />
<p className="font-semibold">Package</p>
<p className="text-sm">{eventPackage.package.name}</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(eventPackage.used_photos / eventPackage.package.max_photos) * 100}%` }}
></div>
</div>
<p className="text-xs text-gray-600 mt-1">
{eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos
</p>
{new Date(eventPackage.expires_at) < new Date() && (
<p className="text-red-600 text-xs mt-1">Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}</p>
<p className="text-sm text-gray-600">{eventPackage.package?.name ?? '—'}</p>
{photoLimits?.limit ? (
<>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200">
<div
className={`h-2 rounded-full ${photoLimits.state === 'limit_reached' ? 'bg-red-500' : photoLimits.state === 'warning' ? 'bg-amber-500' : 'bg-blue-600'}`}
style={{ width: `${Math.min(100, Math.max(6, Math.round((photoLimits.used / photoLimits.limit) * 100))) }%` }}
/>
</div>
<p className="mt-2 text-xs text-gray-600">
{photoLimits.used} / {photoLimits.limit} Fotos
</p>
</>
) : (
<p className="mt-2 text-xs text-gray-600">{t('upload.limitUnlimited')}</p>
)}
{guestLimits?.limit ? (
<p className="mt-2 text-xs text-gray-500">
Gäste: {guestLimits.used} / {guestLimits.limit}
</p>
) : null}
{galleryLimits?.expires_at ? (
<p className="mt-2 text-xs text-gray-500">
Galerie bis {formatDate(galleryLimits.expires_at)}
</p>
) : null}
</div>
)}
</div>
</CardContent>
</Card>

View File

@@ -5,6 +5,14 @@ import BottomNav from '../components/BottomNav';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { cn } from '@/lib/utils';
@@ -22,6 +30,8 @@ import {
} from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
interface Task {
id: number;
@@ -65,19 +75,6 @@ function getErrorName(error: unknown): string | undefined {
return undefined;
}
function getErrorMessage(error: unknown): string | undefined {
if (error instanceof Error && typeof error.message === 'string') {
return error.message;
}
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as { message?: unknown }).message;
return typeof message === 'string' ? message : undefined;
}
return undefined;
}
const DEFAULT_PREFS: CameraPreferences = {
facingMode: 'environment',
countdownSeconds: 3,
@@ -87,6 +84,24 @@ const DEFAULT_PREFS: CameraPreferences = {
flashPreferred: false,
};
const LIMIT_CARD_STYLES: Record<LimitSummaryCard['tone'], { card: string; badge: string; bar: string }> = {
neutral: {
card: 'border-slate-200 bg-white/90 text-slate-900 dark:border-white/15 dark:bg-white/10 dark:text-white',
badge: 'bg-slate-900/10 text-slate-900 dark:bg-white/20 dark:text-white',
bar: 'bg-emerald-500',
},
warning: {
card: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-50',
badge: 'bg-white/70 text-amber-900 dark:bg-amber-400/25 dark:text-amber-50',
bar: 'bg-amber-500',
},
danger: {
card: 'border-rose-200 bg-rose-50 text-rose-900 dark:border-rose-400/50 dark:bg-rose-500/15 dark:text-rose-50',
badge: 'bg-white/70 text-rose-900 dark:bg-rose-400/20 dark:text-rose-50',
bar: 'bg-rose-500',
},
};
export default function UploadPage() {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
@@ -115,12 +130,19 @@ export default function UploadPage() {
const [statusMessage, setStatusMessage] = useState<string>('');
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
const limitCards = useMemo<LimitSummaryCard[]>(
() => buildLimitSummaries(eventPackage?.limits ?? null, t),
[eventPackage?.limits, t]
);
const [showPrimer, setShowPrimer] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
@@ -249,38 +271,55 @@ export default function UploadPage() {
try {
const pkg = await getEventPackage(eventKey);
setEventPackage(pkg);
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
setCanUpload(false);
const maxLabel = pkg.package.max_photos == null
? t('upload.limitUnlimited')
: `${pkg.package.max_photos}`;
setUploadError(
t('upload.limitReached')
.replace('{used}', `${pkg.used_photos}`)
.replace('{max}', maxLabel)
);
} else {
if (!pkg) {
setCanUpload(true);
setUploadError(null);
setUploadWarning(null);
return;
}
if (pkg?.package?.max_photos) {
const max = Number(pkg.package.max_photos);
const used = Number(pkg.used_photos ?? 0);
const ratio = max > 0 ? used / max : 0;
if (ratio >= 0.8 && ratio < 1) {
const remaining = Math.max(0, max - used);
setUploadWarning(
t('upload.limitWarning')
.replace('{remaining}', `${remaining}`)
.replace('{max}', `${max}`)
);
const photoLimits = pkg.limits?.photos ?? null;
const galleryLimits = pkg.limits?.gallery ?? null;
let canUploadCurrent = pkg.limits?.can_upload_photos ?? true;
let errorMessage: string | null = null;
const warnings: string[] = [];
if (photoLimits?.state === 'limit_reached') {
canUploadCurrent = false;
if (typeof photoLimits.limit === 'number') {
errorMessage = t('upload.limitReached')
.replace('{used}', `${photoLimits.used}`)
.replace('{max}', `${photoLimits.limit}`);
} else {
setUploadWarning(null);
errorMessage = t('upload.errors.photoLimit');
}
} else {
setUploadWarning(null);
} else if (
photoLimits?.state === 'warning'
&& typeof photoLimits.remaining === 'number'
&& typeof photoLimits.limit === 'number'
) {
warnings.push(
t('upload.limitWarning')
.replace('{remaining}', `${photoLimits.remaining}`)
.replace('{max}', `${photoLimits.limit}`)
);
}
if (galleryLimits?.state === 'expired') {
canUploadCurrent = false;
errorMessage = t('upload.errors.galleryExpired');
} else if (galleryLimits?.state === 'warning') {
const daysLeft = Math.max(0, galleryLimits.days_remaining ?? 0);
const key = daysLeft === 1 ? 'upload.galleryWarningDay' : 'upload.galleryWarningDays';
warnings.push(
t(key).replace('{days}', `${daysLeft}`)
);
}
setCanUpload(canUploadCurrent);
setUploadError(errorMessage);
setUploadWarning(errorMessage ? null : (warnings.length > 0 ? warnings.join(' · ') : null));
} catch (err) {
console.error('Failed to check package limits', err);
setCanUpload(false);
@@ -543,39 +582,20 @@ export default function UploadPage() {
const uploadErr = error as UploadError;
setUploadWarning(null);
const meta = uploadErr.meta as Record<string, unknown> | undefined;
switch (uploadErr.code) {
case 'photo_limit_exceeded': {
if (meta && typeof meta.used === 'number' && typeof meta.limit === 'number') {
const limitText = t('upload.limitReached')
.replace('{used}', `${meta.used}`)
.replace('{max}', `${meta.limit}`);
setUploadError(limitText);
} else {
setUploadError(t('upload.errors.photoLimit'));
}
setCanUpload(false);
break;
}
case 'upload_device_limit': {
setUploadError(t('upload.errors.deviceLimit'));
setCanUpload(false);
break;
}
case 'event_package_missing':
case 'event_not_found': {
setUploadError(t('upload.errors.packageMissing'));
setCanUpload(false);
break;
}
case 'gallery_expired': {
setUploadError(t('upload.errors.galleryExpired'));
setCanUpload(false);
break;
}
default: {
setUploadError(getErrorMessage(uploadErr) || t('upload.errors.generic'));
}
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, t);
setErrorDialog(dialog);
setUploadError(dialog.description);
if (
uploadErr.code === 'photo_limit_exceeded'
|| uploadErr.code === 'upload_device_limit'
|| uploadErr.code === 'event_package_missing'
|| uploadErr.code === 'event_not_found'
|| uploadErr.code === 'gallery_expired'
) {
setCanUpload(false);
}
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -625,6 +645,54 @@ export default function UploadPage() {
}
}, [resetCountdownTimer]);
const limitStatusSection = limitCards.length > 0 ? (
<section className="mx-4 mb-6 space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-900 dark:text-white">
{t('upload.status.title')}
</h2>
<p className="text-xs text-slate-600 dark:text-white/70">
{t('upload.status.subtitle')}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{limitCards.map((card) => {
const styles = LIMIT_CARD_STYLES[card.tone];
return (
<div
key={card.id}
className={cn(
'rounded-xl border p-4 shadow-sm backdrop-blur transition-colors',
styles.card
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide opacity-70">
{card.label}
</p>
<p className="text-lg font-semibold">{card.valueLabel}</p>
</div>
<Badge className={cn('text-[10px] font-semibold uppercase tracking-wide', styles.badge)}>
{card.badgeLabel}
</Badge>
</div>
{card.progress !== null && (
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-white/60 dark:bg-white/10">
<div
className={cn('h-full rounded-full transition-all', styles.bar)}
style={{ width: `${card.progress}%` }}
/>
</div>
)}
<p className="mt-3 text-sm opacity-80">{card.description}</p>
</div>
);
})}
</div>
</section>
) : null;
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<div className="pb-16">
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
@@ -633,16 +701,56 @@ export default function UploadPage() {
</div>
);
const dialogToneIconClass: Record<Exclude<UploadErrorDialog['tone'], undefined>, string> = {
danger: 'text-rose-500',
warning: 'text-amber-500',
info: 'text-sky-500',
};
const errorDialogNode = (
<Dialog open={Boolean(errorDialog)} onOpenChange={(open) => { if (!open) setErrorDialog(null); }}>
<DialogContent>
<DialogHeader className="space-y-3">
<div className="flex items-center gap-3">
{errorDialog?.tone === 'info' ? (
<Info className={cn('h-5 w-5', dialogToneIconClass.info)} />
) : (
<AlertTriangle className={cn('h-5 w-5', dialogToneIconClass[errorDialog?.tone ?? 'danger'])} />
)}
<DialogTitle>{errorDialog?.title ?? ''}</DialogTitle>
</div>
<DialogDescription>{errorDialog?.description ?? ''}</DialogDescription>
{errorDialog?.hint ? (
<p className="text-sm text-muted-foreground">{errorDialog.hint}</p>
) : null}
</DialogHeader>
<DialogFooter>
<Button onClick={() => setErrorDialog(null)}>{t('upload.dialogs.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
const renderWithDialog = (content: ReactNode, mainClassName = 'px-4 py-6') => (
<>
{renderPage(content, mainClassName)}
{errorDialogNode}
</>
);
if (!supportsCamera && !task) {
return renderPage(
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
return renderWithDialog(
<>
{limitStatusSection}
<Alert>
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
</Alert>
</>
);
}
if (loadingTask) {
return renderPage(
return renderWithDialog(
<div className="flex flex-col items-center justify-center gap-4 text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
@@ -651,15 +759,18 @@ export default function UploadPage() {
}
if (!canUpload) {
return renderPage(
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
return renderWithDialog(
<>
{limitStatusSection}
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('upload.limitReached')
.replace('{used}', `${eventPackage?.used_photos || 0}`)
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
</AlertDescription>
</Alert>
</>
);
}
@@ -711,13 +822,14 @@ export default function UploadPage() {
);
};
return renderPage(
return renderWithDialog(
<>
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}
</div>
<div className="pt-32" />
{permissionState !== 'granted' && renderPermissionNotice()}
{limitStatusSection}
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
<div className="relative aspect-[3/4] sm:aspect-video">

View File

@@ -19,13 +19,48 @@ export interface PackageData {
id: number;
name: string;
max_photos: number;
max_guests?: number | null;
gallery_days?: number | null;
}
export interface 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[];
}
export interface 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;
}
export interface EventPackageLimits {
photos: LimitUsageSummary | null;
guests: LimitUsageSummary | null;
gallery: GallerySummary | null;
can_upload_photos: boolean;
can_add_guests: boolean;
}
export interface EventPackage {
id: number;
event_id?: number;
package_id?: number;
used_photos: number;
expires_at: string;
package: PackageData;
used_guests?: number;
expires_at: string | null;
package: PackageData | null;
limits: EventPackageLimits | null;
}
export interface EventStats {
@@ -39,6 +74,8 @@ export type FetchEventErrorCode =
| 'token_expired'
| 'token_revoked'
| 'token_rate_limited'
| 'access_rate_limited'
| 'gallery_expired'
| 'event_not_public'
| 'network_error'
| 'server_error'
@@ -195,5 +232,9 @@ export async function getEventPackage(eventToken: string): Promise<EventPackage
if (res.status === 404) return null;
throw new Error('Failed to load event package');
}
return await res.json();
const payload = await res.json();
return {
...payload,
limits: payload?.limits ?? null,
} as EventPackage;
}

View File

@@ -82,7 +82,7 @@ const Home: React.FC<Props> = ({ packages }) => {
</div>
<div className="md:w-1/2">
<img
src="https://via.placeholder.com/600x400/FFB6C1/FFFFFF?text=Fotospiel+Hero"
src="/joyous_wedding_guests_posing.jpg"
alt={t('home.hero_image_alt')}
className="w-full h-auto rounded-lg shadow-lg"
/>