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

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