Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user