der tenant admin hat eine neue, mobil unterstützende UI, login redirect funktioniert, typescript fehler wurden bereinigt. Neue Blog Posts von ChatGPT eingebaut, übersetzt von Gemini 2.5

This commit is contained in:
Codex Agent
2025-11-05 19:27:10 +01:00
parent adb93b5f9d
commit c6ac04eb15
44 changed files with 1995 additions and 1949 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import { ApiError, emitApiErrorEvent } from './lib/apiError';
import type { EventLimitSummary } from './lib/limitWarnings';
import i18n from './i18n';
type JsonValue = Record<string, unknown>;
type JsonValue = Record<string, any>;
export type TenantAccountProfile = {
id: number;
@@ -98,6 +98,7 @@ export type TenantPhoto = {
likes_count: number;
uploaded_at: string;
uploader_name: string | null;
caption?: string | null;
};
export type EventStats = {
@@ -107,6 +108,10 @@ export type EventStats = {
recent_uploads: number;
status: string;
is_active: boolean;
uploads_total?: number;
uploads_24h?: number;
likes_total?: number;
pending_photos?: number;
};
export type PaginationMeta = {
@@ -156,7 +161,9 @@ export async function trackOnboarding(step: string, meta?: Record<string, unknow
body: JSON.stringify({ step, meta }),
});
} catch (error) {
emitApiErrorEvent(new ApiError('onboarding.track_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error));
const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.');
emitApiErrorEvent({ message, code: 'onboarding.track_failed' });
console.error('[Onboarding] Failed to track tenant onboarding step', error);
}
}
@@ -165,7 +172,9 @@ export async function fetchOnboardingStatus(): Promise<TenantOnboardingStatus |
const response = await authorizedFetch('/api/v1/tenant/onboarding');
return (await response.json()) as TenantOnboardingStatus;
} catch (error) {
emitApiErrorEvent(new ApiError('onboarding.fetch_failed', i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.'), error));
const message = i18n.t('common.errors.generic', 'Etwas ist schiefgelaufen.');
emitApiErrorEvent({ message, code: 'onboarding.fetch_failed' });
console.error('[Onboarding] Failed to fetch tenant onboarding status', error);
return null;
}
}
@@ -232,6 +241,7 @@ export type TenantTask = {
difficulty: 'easy' | 'medium' | 'hard' | null;
due_date: string | null;
is_completed: boolean;
tenant_id: number | null;
collection_id: number | null;
source_task_id: number | null;
source_collection_id: number | null;
@@ -401,14 +411,17 @@ async function jsonOrThrow<T>(response: Response, message: string, options: Json
const body = await safeJson(response);
const status = response.status;
const errorPayload = body && typeof body === 'object' ? (body as Record<string, unknown>).error : null;
const errorMessage = (errorPayload && typeof errorPayload === 'object' && 'message' in errorPayload && typeof errorPayload.message === 'string')
? errorPayload.message
const errorRecord = errorPayload && typeof errorPayload === 'object'
? (errorPayload as Record<string, unknown>)
: null;
const errorMessage = errorRecord && typeof errorRecord.message === 'string'
? errorRecord.message
: message;
const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string'
? errorPayload.code
const errorCode = errorRecord && typeof errorRecord.code === 'string'
? errorRecord.code
: undefined;
let errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
? errorPayload.meta as Record<string, unknown>
let errorMeta = errorRecord && typeof errorRecord.meta === 'object'
? (errorRecord.meta ?? null) as Record<string, unknown>
: undefined;
if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') {
@@ -587,6 +600,7 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
likes_count: Number(photo.likes_count ?? 0),
uploaded_at: photo.uploaded_at,
uploader_name: photo.uploader_name ?? null,
caption: photo.caption ?? null,
};
}
@@ -654,7 +668,6 @@ function normalizeTask(task: JsonValue): TenantTask {
return {
id: Number(task.id ?? 0),
tenant_id: task.tenant_id ?? null,
slug: String(task.slug ?? `task-${task.id ?? ''}`),
title: pickTranslatedText(titleTranslations, 'Ohne Titel'),
title_translations: titleTranslations,
@@ -668,6 +681,7 @@ function normalizeTask(task: JsonValue): TenantTask {
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
due_date: task.due_date ?? null,
is_completed: Boolean(task.is_completed ?? false),
tenant_id: task.tenant_id ?? null,
collection_id: task.collection_id ?? null,
source_task_id: task.source_task_id ?? null,
source_collection_id: task.source_collection_id ?? null,
@@ -680,7 +694,11 @@ function normalizeTask(task: JsonValue): TenantTask {
function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true);
const descriptionTranslations = normalizeTranslationMap(
raw.description_translations ?? raw.description ?? {},
undefined,
true
);
const eventTypeRaw = raw.event_type ?? raw.eventType ?? null;
let eventType: TenantTaskCollection['event_type'] = null;
@@ -715,7 +733,11 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
function normalizeEmotion(raw: JsonValue): TenantEmotion {
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true);
const descriptionTranslations = normalizeTranslationMap(
raw.description_translations ?? raw.description ?? {},
undefined,
true
);
const eventTypes = Array.isArray(raw.event_types ?? raw.eventTypes)
? (raw.event_types ?? raw.eventTypes)
@@ -906,6 +928,10 @@ export async function getEventStats(slug: string): Promise<EventStats> {
recent_uploads: Number(data.recent_uploads ?? 0),
status: data.status ?? 'draft',
is_active: Boolean(data.is_active),
uploads_total: Number((data as JsonValue).uploads_total ?? data.total ?? 0),
uploads_24h: Number((data as JsonValue).uploads_24h ?? data.recent_uploads ?? 0),
likes_total: Number((data as JsonValue).likes_total ?? data.likes ?? 0),
pending_photos: Number((data as JsonValue).pending_photos ?? 0),
};
}
@@ -1006,8 +1032,8 @@ export async function getEventToolkit(slug: string): Promise<EventToolkit> {
: [],
},
photos: {
pending: pendingPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
recent: recentPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
pending: pendingPhotosRaw.map((photo: JsonValue) => normalizePhoto(photo as TenantPhoto)),
recent: recentPhotosRaw.map((photo: JsonValue) => normalizePhoto(photo as TenantPhoto)),
},
invites: {
summary: {

View File

@@ -10,15 +10,22 @@ import {
ADMIN_BILLING_PATH,
ADMIN_ENGAGEMENT_PATH,
} from '../constants';
import {
LayoutDashboard,
CalendarDays,
Sparkles,
CreditCard,
Settings as SettingsIcon,
} from 'lucide-react';
import { LanguageSwitcher } from './LanguageSwitcher';
import { registerApiErrorListener } from '../lib/apiError';
const navItems = [
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement' },
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' },
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true },
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events', icon: CalendarDays },
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement', icon: Sparkles },
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing', icon: CreditCard },
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings', icon: SettingsIcon },
];
interface AdminLayoutProps {
@@ -51,43 +58,85 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
}, [t]);
return (
<div className="min-h-screen bg-brand-gradient text-brand-slate">
<header className="border-b border-brand-rose-soft bg-brand-card/90 shadow-brand-primary backdrop-blur-md">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.28),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_65%)]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/60 to-[#1d1130]" />
<div className="relative z-10 flex min-h-svh flex-col">
<header className="sticky top-0 z-30 px-4 pt-6 sm:px-6">
<div className="mx-auto w-full max-w-6xl rounded-3xl border border-white/15 bg-white/85 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl">
<div className="flex flex-col gap-6 px-6 py-6 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">{t('app.brand')}</p>
<h1 className="font-display text-3xl font-semibold text-brand-slate">{title}</h1>
{subtitle && <p className="mt-1 text-sm font-sans-marketing text-brand-navy/75">{subtitle}</p>}
<p className="text-xs uppercase tracking-[0.35em] text-rose-400">{t('app.brand')}</p>
<h1 className="font-display text-3xl font-semibold text-slate-900">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-slate-600">{subtitle}</p> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<LanguageSwitcher />
{actions}
</div>
</div>
<nav className="mx-auto flex w-full max-w-6xl gap-3 px-6 pb-4 text-sm font-medium text-brand-navy/80">
{navItems.map((item) => (
<nav className="hidden items-center gap-2 border-t border-white/20 px-6 py-4 md:flex">
{navItems.map(({ to, labelKey, icon: Icon, end }) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'rounded-full px-4 py-2 transition-colors',
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-200',
isActive
? 'bg-brand-rose text-white shadow-md shadow-rose-400/40'
: 'bg-white/70 text-brand-navy/80 hover:bg-white hover:text-brand-slate'
? 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30'
: 'bg-white/70 text-slate-600 hover:bg-white hover:text-slate-900'
)
}
>
{t(item.labelKey)}
<Icon className="h-4 w-4" />
{t(labelKey)}
</NavLink>
))}
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-6xl px-6 py-10">
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-28 pt-6 sm:px-6">
<div className="grid gap-6">{children}</div>
</main>
<TenantMobileNav items={navItems} />
</div>
</div>
);
}
function TenantMobileNav({ items }: { items: typeof navItems }) {
const { t } = useTranslation('common');
return (
<nav className="sticky bottom-4 z-40 px-4 pb-2 md:hidden">
<div className="mx-auto flex w-full max-w-md items-center justify-around rounded-full border border-white/20 bg-white/90 p-2 text-slate-600 shadow-2xl shadow-rose-300/20 backdrop-blur-xl">
{items.map(({ to, labelKey, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-1 rounded-full px-3 py-2 text-xs font-medium transition',
isActive
? 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30'
: 'text-slate-600 hover:text-slate-900'
)
}
>
<Icon className="h-5 w-5" />
<span>{t(labelKey)}</span>
</NavLink>
))}
</div>
</nav>
);
}

View File

@@ -120,3 +120,5 @@ export function DevTenantSwitcher() {
</div>
);
}
export default DevTenantSwitcher;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { CheckCircle2, Circle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type ChecklistAction = {
label: string;
onClick: () => void;
disabled?: boolean;
};
type ChecklistRowProps = {
icon: React.ReactNode;
label: string;
hint?: string;
completed: boolean;
status: { complete: string; pending: string };
action?: ChecklistAction;
className?: string;
};
export function ChecklistRow({ icon, label, hint, completed, status, action, className }: ChecklistRowProps) {
return (
<div
className={cn(
'flex flex-col gap-3 rounded-2xl border border-white/20 bg-white/90 p-4 text-slate-900 transition duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md hover:shadow-rose-300/20 md:flex-row md:items-center md:justify-between',
'dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100',
className
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'flex h-11 w-11 items-center justify-center rounded-full text-base transition-colors duration-200',
completed ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300' : 'bg-slate-200/80 text-slate-500 dark:bg-slate-800/50'
)}
>
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-semibold leading-tight">{label}</p>
{hint ? <p className="text-xs text-slate-600 dark:text-slate-400">{hint}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-flex items-center gap-1 text-xs font-medium',
completed ? 'text-emerald-600 dark:text-emerald-300' : 'text-slate-500 dark:text-slate-400'
)}
>
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{completed ? status.complete : status.pending}
</span>
{action ? (
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
{action.label}
</Button>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
export const frostedCardClass = cn(
'border border-white/15 bg-white/92 text-slate-900 shadow-lg shadow-rose-400/10 backdrop-blur-lg',
'dark:border-slate-800/70 dark:bg-slate-950/85 dark:text-slate-100'
);
type FrostedCardProps = React.ComponentProps<typeof Card>;
export function FrostedCard({ className, ...props }: FrostedCardProps) {
return <Card className={cn(frostedCardClass, className)} {...props} />;
}
type FrostedSurfaceProps = React.HTMLAttributes<HTMLDivElement> & {
asChild?: boolean;
};
export function FrostedSurface({ className, ...props }: FrostedSurfaceProps) {
return (
<div
className={cn(
'rounded-2xl border border-white/15 bg-white/88 text-slate-900 shadow-lg shadow-rose-300/10 backdrop-blur-lg',
'dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100',
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
type TenantHeroCardProps = {
badge?: string;
title: string;
description?: string;
supporting?: string[];
primaryAction?: React.ReactNode;
secondaryAction?: React.ReactNode;
aside?: React.ReactNode;
children?: React.ReactNode;
className?: string;
};
export function TenantHeroCard({
badge,
title,
description,
supporting,
primaryAction,
secondaryAction,
aside,
children,
className,
}: TenantHeroCardProps) {
return (
<Card
className={cn(
'relative overflow-hidden border border-white/15 bg-white/95 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl',
'dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-100',
className
)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.3),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.3),_transparent_65%)] motion-safe:animate-[aurora_18s_ease-in-out_infinite]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/80 via-slate-900/20 to-transparent mix-blend-overlay" />
<CardContent className="relative z-10 flex flex-col gap-8 px-6 py-8 lg:flex-row lg:items-start lg:justify-between lg:px-10 lg:py-12">
<div className="max-w-2xl space-y-6 text-white">
{badge ? (
<span className="inline-flex items-center gap-2 rounded-full border border-white/40 bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-[0.35em]">
{badge}
</span>
) : null}
<div className="space-y-3">
<h1 className="font-display text-3xl tracking-tight sm:text-4xl">{title}</h1>
{description ? <p className="text-sm text-white/80 sm:text-base">{description}</p> : null}
{supporting?.map((paragraph) => (
<p key={paragraph} className="text-sm text-white/75 sm:text-base">
{paragraph}
</p>
))}
{children}
</div>
{(primaryAction || secondaryAction) && (
<div className="flex flex-wrap gap-2">
{primaryAction}
{secondaryAction}
</div>
)}
</div>
{aside ? <div className="w-full max-w-sm">{aside}</div> : null}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,6 @@
export { TenantHeroCard } from './hero-card';
export { FrostedCard, FrostedSurface, frostedCardClass } from './frosted-surface';
export { ChecklistRow } from './checklist-row';
export type { ChecklistStep } from './onboarding-checklist-card';
export { TenantOnboardingChecklistCard } from './onboarding-checklist-card';

View File

@@ -0,0 +1,94 @@
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { FrostedCard } from './frosted-surface';
import { ChecklistRow } from './checklist-row';
export type ChecklistStep = {
key: string;
title: string;
description: string;
done: boolean;
ctaLabel?: string | null;
onAction?: () => void;
icon: LucideIcon;
};
type TenantOnboardingChecklistCardProps = {
title: string;
description?: string;
steps: ChecklistStep[];
completedLabel: string;
pendingLabel: string;
completionPercent: number;
completedCount: number;
totalCount: number;
emptyCopy?: string;
fallbackActionLabel?: string;
};
export function TenantOnboardingChecklistCard({
title,
description,
steps,
completedLabel,
pendingLabel,
completionPercent,
completedCount,
totalCount,
emptyCopy,
fallbackActionLabel,
}: TenantOnboardingChecklistCardProps) {
return (
<FrostedCard className="relative overflow-hidden">
<CardHeader className="relative flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div
aria-hidden
className="pointer-events-none absolute -top-12 -right-16 h-32 w-32 rounded-full bg-rose-200/40 blur-3xl"
/>
<div className="relative">
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
<Badge className="bg-brand-rose-soft text-brand-rose">
{completionPercent}% · {completedCount}/{totalCount}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<Progress value={completionPercent} className="h-2 bg-rose-100 motion-safe:animate-pulse" />
<div className="space-y-3">
{steps.map((step) => {
const Icon = step.icon;
return (
<ChecklistRow
key={step.key}
icon={<Icon className="h-5 w-5" />}
label={step.title}
hint={step.description}
completed={step.done}
status={{ complete: completedLabel, pending: pendingLabel }}
action={
step.done || (!step.ctaLabel && !fallbackActionLabel)
? undefined
: {
label: step.ctaLabel ?? fallbackActionLabel ?? '',
onClick: () => step.onAction?.(),
disabled: !step.onAction,
}
}
/>
);
})}
</div>
{steps.length === 0 && emptyCopy ? (
<p className="text-sm text-slate-600 dark:text-slate-300">{emptyCopy}</p>
) : null}
</CardContent>
</FrostedCard>
);
}

View File

@@ -4,7 +4,7 @@ export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`;
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH;
export const ADMIN_HOME_PATH = adminPath('/dashboard');
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = adminPath('/events');
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = adminPath('/dashboard');
export const ADMIN_LOGIN_PATH = adminPath('/login');
export const ADMIN_LOGIN_START_PATH = adminPath('/start');
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');

View File

@@ -109,10 +109,8 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
// @ts-expect-error Dev helper for debugging only.
window.fotospielDemoAuth = api;
// @ts-expect-error Dev helper for debugging only.
globalThis.fotospielDemoAuth = api;
(window as typeof window & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
(globalThis as typeof globalThis & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
}
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {

View File

@@ -47,6 +47,29 @@
"button": "Zum Event-Manager"
}
},
"landingProgress": {
"eyebrow": "Onboarding-Tracker",
"title": "Bleib mit dem Marketing-Dashboard synchron",
"description": "Erledige diese schnellen Schritte, damit der Onboarding-Status im Marketing-Dashboard aktuell bleibt.",
"status": {
"complete": "Erledigt",
"pending": "Offen"
},
"steps": {
"package": {
"title": "Paket sichern",
"hint": "Credits oder ein Abo brauchst du, bevor Gäste live gehen."
},
"invite": {
"title": "Team einladen",
"hint": "Teile Moderation und Galeriepflege mit deinem Team."
},
"branding": {
"title": "Branding einstellen",
"hint": "Passe Farben und Logo für das Gasterlebnis an."
}
}
},
"packages": {
"layout": {
"eyebrow": "Schritt 2",

View File

@@ -47,6 +47,29 @@
"button": "Go to event manager"
}
},
"landingProgress": {
"eyebrow": "Onboarding tracker",
"title": "Stay aligned with your marketing dashboard",
"description": "Complete these quick wins so the marketing dashboard reflects your latest tenant progress.",
"status": {
"complete": "Completed",
"pending": "Pending"
},
"steps": {
"package": {
"title": "Secure your package",
"hint": "Credits or a subscription are required before guests go live."
},
"invite": {
"title": "Invite your co-hosts",
"hint": "Share moderation and gallery duties with your team."
},
"branding": {
"title": "Apply your branding",
"hint": "Match colours and logo across the guest experience."
}
}
},
"packages": {
"layout": {
"eyebrow": "Step 2",

View File

@@ -1,6 +1,7 @@
import { ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants';
import { ADMIN_BASE_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH } from '../constants';
const LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination';
const DASHBOARD_PREFIX = '/dashboard';
function ensureLeadingSlash(target: string): string {
if (!target) {
@@ -14,6 +15,28 @@ function ensureLeadingSlash(target: string): string {
return target.startsWith('/') ? target : `/${target}`;
}
function matchesDashboardScope(path: string): boolean {
return (
path === DASHBOARD_PREFIX ||
path.startsWith(`${DASHBOARD_PREFIX}?`) ||
path.startsWith(`${DASHBOARD_PREFIX}/`)
);
}
export function isPermittedReturnTarget(target: string): boolean {
if (!target) {
return false;
}
const sanitized = ensureLeadingSlash(target);
if (sanitized.startsWith(ADMIN_BASE_PATH)) {
return true;
}
return matchesDashboardScope(sanitized);
}
function base64UrlEncode(value: string): string {
const encoder = new TextEncoder();
const bytes = encoder.encode(value);
@@ -65,16 +88,19 @@ export interface ReturnTargetResolution {
export function resolveReturnTarget(raw: string | null, fallback: string): ReturnTargetResolution {
const normalizedFallback = ensureLeadingSlash(fallback);
const fallbackIsAllowed = isPermittedReturnTarget(normalizedFallback)
? normalizedFallback
: ADMIN_DEFAULT_AFTER_LOGIN_PATH;
if (!raw) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
const encoded = encodeReturnTo(fallbackIsAllowed);
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
}
const decodedPrimary = decodeReturnTo(raw);
if (!decodedPrimary) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
const encoded = encodeReturnTo(fallbackIsAllowed);
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
}
const normalizedPrimary = decodedPrimary.trim();
@@ -86,20 +112,25 @@ export function resolveReturnTarget(raw: string | null, fallback: string): Retur
const url = new URL(normalizedPrimary, window.location.origin);
const innerRaw = url.searchParams.get('return_to');
if (!innerRaw) {
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
const encoded = encodeReturnTo(fallbackIsAllowed);
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
}
return resolveReturnTarget(innerRaw, normalizedFallback);
return resolveReturnTarget(innerRaw, fallbackIsAllowed);
} catch (error) {
console.warn('[Auth] Failed to parse return_to chain', error);
const encoded = encodeReturnTo(normalizedFallback);
return { finalTarget: normalizedFallback, encodedFinal: encoded };
const encoded = encodeReturnTo(fallbackIsAllowed);
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
}
}
}
const finalTarget = ensureLeadingSlash(normalizedPrimary);
if (!isPermittedReturnTarget(finalTarget)) {
const encoded = encodeReturnTo(fallbackIsAllowed);
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
}
const encodedFinal = encodeReturnTo(finalTarget);
return { finalTarget, encodedFinal };

View File

@@ -1,6 +1,8 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { TFunction } from 'i18next';
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
@@ -21,12 +23,13 @@ describe('PaddleCheckout', () => {
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
const onSuccess = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const tMock = ((key: string) => key) as unknown as TFunction;
render(
<PaddleCheckout
packageId={99}
onSuccess={onSuccess}
t={(key: string) => key}
t={tMock}
/>
);
@@ -45,12 +48,13 @@ describe('PaddleCheckout', () => {
it('shows an error message on failure', async () => {
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
const tMock = ((key: string) => key) as unknown as TFunction;
render(
<PaddleCheckout
packageId={99}
onSuccess={vi.fn()}
t={(key: string) => key}
t={tMock}
/>
);
@@ -59,7 +63,7 @@ describe('PaddleCheckout', () => {
});
await waitFor(() => {
expect(screen.getByText('summary.paddle.genericError')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('boom');
});
});
});

View File

@@ -3,6 +3,8 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { LucideIcon } from 'lucide-react';
import { FrostedSurface } from '../../components/tenant';
export interface OnboardingAction {
id: string;
label: string;
@@ -28,30 +30,30 @@ export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps
return (
<div className={cn('grid gap-4 md:grid-cols-2', className)}>
{actions.map(({ id, label, description, href, onClick, icon: Icon, variant = 'primary', disabled, buttonLabel }) => (
<div
<FrostedSurface
key={id}
className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-brand-primary backdrop-blur"
className="flex flex-col gap-3 border border-white/20 p-5 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<div className="flex items-center gap-3">
{Icon && (
<span className="flex size-10 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
{Icon ? (
<span className="flex size-10 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<Icon className="size-5" />
</span>
)}
<span className="text-base font-semibold text-brand-slate">{label}</span>
) : null}
<span className="text-base font-semibold text-slate-900 dark:text-slate-100">{label}</span>
</div>
{description && (
<p className="text-sm text-brand-navy/80">{description}</p>
)}
{description ? (
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
) : null}
<div>
<Button
variant="default"
size="lg"
className={cn(
'w-full rounded-full transition-all',
'w-full rounded-full transition-colors',
variant === 'secondary'
? 'bg-brand-gold text-brand-slate shadow-md shadow-amber-200/40 hover:bg-[var(--brand-gold-soft)]'
: 'bg-brand-rose text-white shadow-md shadow-rose-400/30 hover:bg-[var(--brand-rose-strong)]'
? 'bg-slate-900/80 text-white shadow-md shadow-slate-900/30 hover:bg-slate-900 dark:bg-slate-800'
: 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]'
)}
disabled={disabled}
onClick={onClick}
@@ -60,7 +62,7 @@ export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps
{href ? <a href={href}>{buttonLabel ?? label}</a> : buttonLabel ?? label}
</Button>
</div>
</div>
</FrostedSurface>
))}
</div>
);

View File

@@ -1,8 +1,10 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedSurface } from '../../components/tenant';
export interface HighlightItem {
id: string;
icon: LucideIcon;
@@ -24,27 +26,27 @@ export function OnboardingHighlightsGrid({ items, className }: OnboardingHighlig
return (
<div className={cn('grid gap-4 md:grid-cols-3', className)}>
{items.map(({ id, icon: Icon, title, description, badge }) => (
<Card
<FrostedSurface
key={id}
className="relative overflow-hidden rounded-3xl border border-white/70 bg-white/90 shadow-xl shadow-rose-100/40"
className="relative overflow-hidden rounded-3xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between">
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100 text-rose-500 shadow-inner">
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<Icon className="size-6" />
</span>
{badge && (
<span className="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-rose-500">
<span className="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-rose-500 dark:bg-rose-500/20 dark:text-rose-200">
{badge}
</span>
)}
</div>
<CardTitle className="text-lg font-semibold text-slate-900">{title}</CardTitle>
<CardTitle className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600">{description}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</CardContent>
</Card>
</FrostedSurface>
))}
</div>
);

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { LanguageSwitcher } from '../../components/LanguageSwitcher';
import { FrostedSurface } from '../../components/tenant';
export interface TenantWelcomeLayoutProps {
eyebrow?: string;
@@ -27,24 +28,28 @@ export function TenantWelcomeLayout({
}, []);
return (
<div className="min-h-screen w-full bg-brand-gradient text-brand-slate transition-colors duration-500 ease-out">
<div className="mx-auto flex min-h-screen w-full max-w-5xl px-6 py-12 md:py-16 lg:px-10">
<div className="flex w-full flex-col gap-10 rounded-[40px] border border-brand-rose-soft bg-brand-card p-8 shadow-brand-primary backdrop-blur-xl md:gap-14 md:p-14">
<div className="relative min-h-svh w-full overflow-hidden bg-slate-950 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.28),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_65%)] motion-safe:animate-[aurora_20s_ease-in-out_infinite]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/70 to-[#1d1130]" />
<div className="relative z-10 mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-10 px-6 py-12 sm:px-8 md:py-16 lg:px-12">
<FrostedSurface className="flex w-full flex-1 flex-col gap-10 rounded-[36px] border border-white/15 p-8 text-slate-900 shadow-2xl shadow-rose-300/20 backdrop-blur-2xl transition-colors duration-200 dark:text-slate-100 md:gap-14 md:p-14">
<header className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div className="max-w-xl">
{eyebrow && (
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">{eyebrow}</p>
)}
{title && (
<h1 className="mt-2 font-display text-4xl font-semibold tracking-tight text-brand-slate md:text-5xl">
<div className="max-w-xl space-y-4">
{eyebrow ? (
<p className="text-xs uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200">{eyebrow}</p>
) : null}
{title ? (
<h1 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 md:text-5xl">
{title}
</h1>
)}
{subtitle && (
<p className="mt-4 text-base font-sans-marketing text-brand-navy/80 md:text-lg">
{subtitle}
</p>
)}
) : null}
{subtitle ? (
<p className="text-base text-slate-600 dark:text-slate-300 md:text-lg">{subtitle}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<LanguageSwitcher />
@@ -56,12 +61,12 @@ export function TenantWelcomeLayout({
{children}
</main>
{footer && (
<footer className="flex flex-col items-center gap-4 text-sm text-brand-navy/70 md:flex-row md:justify-between">
{footer ? (
<footer className="flex flex-col items-center gap-4 text-sm text-slate-600 dark:text-slate-400 md:flex-row md:justify-between">
{footer}
</footer>
)}
</div>
) : null}
</FrostedSurface>
</div>
</div>
);

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedSurface } from '../../components/tenant';
interface ActionProps {
label: string;
@@ -29,28 +30,32 @@ export function WelcomeHero({
className,
}: WelcomeHeroProps) {
return (
<section
<FrostedSurface
className={cn(
'rounded-3xl border border-brand-rose-soft bg-brand-card p-8 shadow-brand-primary backdrop-blur-xl',
'relative overflow-hidden rounded-3xl border border-white/15 p-8 text-slate-900 shadow-2xl shadow-rose-300/20 backdrop-blur-2xl transition-colors duration-200 dark:text-slate-100 md:p-12',
className
)}
>
<div className="space-y-4 text-center md:space-y-6">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-[-30%] h-[220px] bg-[radial-gradient(circle,_rgba(255,137,170,0.35),_transparent_60%)]"
/>
<div className="relative space-y-4 text-center md:space-y-6">
{eyebrow && (
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose md:text-sm">
<p className="text-xs uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200 md:text-sm">
{eyebrow}
</p>
)}
<h2 className="font-display text-3xl font-semibold tracking-tight text-brand-slate md:text-4xl">
<h2 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 md:text-4xl">
{title}
</h2>
{scriptTitle && (
<p className="font-script text-2xl text-brand-rose md:text-3xl">
<p className="font-script text-2xl text-rose-300 md:text-3xl">
{scriptTitle}
</p>
)}
{description && (
<p className="mx-auto max-w-2xl text-base font-sans-marketing text-brand-navy/80 md:text-lg">
<p className="mx-auto max-w-2xl text-base text-slate-600 dark:text-slate-300 md:text-lg">
{description}
</p>
)}
@@ -62,10 +67,10 @@ export function WelcomeHero({
size="lg"
variant={variant === 'outline' ? 'outline' : 'default'}
className={cn(
'min-w-[220px] rounded-full px-6',
'min-w-[220px] rounded-full px-6 transition-colors',
variant === 'outline'
? 'border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40'
: 'bg-brand-rose text-white shadow-md shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]'
? 'border-white/60 bg-white/20 text-slate-900 hover:bg-white/40 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-100'
: 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]'
)}
onClick={onClick}
{...(href ? { asChild: true } : {})}
@@ -86,7 +91,7 @@ export function WelcomeHero({
</div>
)}
</div>
</section>
</FrostedSurface>
);
}

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedCard } from '../../components/tenant';
export interface WelcomeStepCardProps {
step: number;
totalSteps: number;
@@ -27,16 +29,16 @@ export function WelcomeStepCard({
const percent = totalSteps <= 1 ? 100 : Math.round((progress / totalSteps) * 100);
return (
<Card
<FrostedCard
className={cn(
'relative overflow-hidden rounded-3xl border border-brand-rose-soft bg-brand-card shadow-brand-primary',
'relative overflow-hidden rounded-3xl border border-white/20 text-slate-900 shadow-lg shadow-rose-200/20 dark:text-slate-100',
className
)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-brand-rose-soft via-[var(--brand-gold-soft)] to-brand-sky-soft" />
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1]" />
<CardHeader className="space-y-4 pt-8">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.4em] text-brand-rose">
<span className="text-xs font-semibold uppercase tracking-[0.4em] text-rose-300 dark:text-rose-200">
Step {progress} / {totalSteps}
</span>
<div className="w-28">
@@ -45,20 +47,20 @@ export function WelcomeStepCard({
</div>
<div className="flex items-start gap-4">
{Icon && (
<span className="flex size-12 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100/90 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<Icon className="size-5" />
</span>
)}
<div className="space-y-2">
<CardTitle className="font-display text-2xl font-semibold text-brand-slate md:text-3xl">{title}</CardTitle>
<CardTitle className="font-display text-2xl font-semibold text-slate-900 dark:text-slate-100 md:text-3xl">{title}</CardTitle>
{description && (
<CardDescription className="text-base font-sans-marketing text-brand-navy/80">{description}</CardDescription>
<CardDescription className="text-base text-slate-600 dark:text-slate-400">{description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pb-10">{children}</CardContent>
</Card>
<CardContent className="space-y-6 pb-10 text-slate-700 dark:text-slate-300">{children}</CardContent>
</FrostedCard>
);
}

View File

@@ -11,6 +11,7 @@ import {
} from "..";
import { Button } from "@/components/ui/button";
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants";
import { FrostedSurface } from "../../components/tenant";
export default function WelcomeEventSetupPage() {
const navigate = useNavigate();
@@ -55,25 +56,25 @@ export default function WelcomeEventSetupPage() {
icon: ArrowRight,
},
].map((item) => (
<div
<FrostedSurface
key={item.id}
className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-brand-primary"
className="flex flex-col gap-3 border border-white/20 p-5 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100"
>
<span className="flex size-10 items-center justify-center rounded-full bg-brand-rose-soft text-brand-rose shadow-inner">
<span className="flex size-10 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<item.icon className="size-5" />
</span>
<h3 className="text-lg font-semibold text-brand-slate">{item.title}</h3>
<p className="text-sm text-brand-navy/80">{item.copy}</p>
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.copy}</p>
</FrostedSurface>
))}
</div>
<div className="mt-6 flex flex-col items-start gap-3 rounded-3xl border border-brand-rose-soft bg-brand-sky-soft/40 p-6 text-brand-navy">
<h4 className="text-lg font-semibold text-brand-rose">{t("eventSetup.cta.heading")}</h4>
<p className="text-sm text-brand-navy/80">{t("eventSetup.cta.description")}</p>
<FrostedSurface className="mt-6 flex flex-col items-start gap-3 border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<h4 className="text-lg font-semibold text-rose-400 dark:text-rose-200">{t("eventSetup.cta.heading")}</h4>
<p className="text-sm text-slate-600 dark:text-slate-400">{t("eventSetup.cta.description")}</p>
<Button
size="lg"
className="mt-2 rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
className="mt-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => {
markStep({ lastStep: "event-create-intent" });
navigate(ADMIN_EVENT_CREATE_PATH);
@@ -82,7 +83,7 @@ export default function WelcomeEventSetupPage() {
{t("eventSetup.cta.button")}
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
</FrostedSurface>
</WelcomeStepCard>
<OnboardingCTAList

View File

@@ -1,7 +1,7 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sparkles, Users, Camera, CalendarDays, ChevronRight } from "lucide-react";
import { Sparkles, Users, Camera, CalendarDays, ChevronRight, CreditCard, UserPlus, Palette } from "lucide-react";
import {
TenantWelcomeLayout,
@@ -11,16 +11,52 @@ import {
useOnboardingProgress,
} from "..";
import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants";
import { ChecklistRow, FrostedSurface } from "../../components/tenant";
export default function WelcomeLandingPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
const { markStep, progress } = useOnboardingProgress();
const { t } = useTranslation("onboarding");
React.useEffect(() => {
markStep({ welcomeSeen: true, lastStep: "landing" });
}, [markStep]);
const progressStatus = React.useMemo(
() => ({
complete: t("landingProgress.status.complete"),
pending: t("landingProgress.status.pending"),
}),
[t]
);
const progressSteps = React.useMemo(
() => [
{
key: "package",
icon: <CreditCard className="h-5 w-5" />,
label: t("landingProgress.steps.package.title"),
hint: t("landingProgress.steps.package.hint"),
completed: progress.packageSelected,
},
{
key: "invite",
icon: <UserPlus className="h-5 w-5" />,
label: t("landingProgress.steps.invite.title"),
hint: t("landingProgress.steps.invite.hint"),
completed: progress.inviteCreated,
},
{
key: "branding",
icon: <Palette className="h-5 w-5" />,
label: t("landingProgress.steps.branding.title"),
hint: t("landingProgress.steps.branding.hint"),
completed: progress.brandingConfigured,
},
],
[progress.packageSelected, progress.inviteCreated, progress.brandingConfigured, t]
);
return (
<TenantWelcomeLayout
eyebrow={t("layout.eyebrow")}
@@ -60,6 +96,32 @@ export default function WelcomeLandingPage() {
]}
/>
<FrostedSurface className="space-y-4 rounded-3xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200">
{t("landingProgress.eyebrow")}
</p>
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
{t("landingProgress.title")}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t("landingProgress.description")}
</p>
</div>
<div className="grid gap-3">
{progressSteps.map((step) => (
<ChecklistRow
key={step.key}
icon={step.icon}
label={step.label}
hint={step.hint}
completed={step.completed}
status={progressStatus}
/>
))}
</div>
</FrostedSurface>
<OnboardingHighlightsGrid
items={[
{

View File

@@ -1,4 +1,4 @@
import React from "react";
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
@@ -8,6 +8,7 @@ import {
ArrowLeft,
CreditCard,
AlertTriangle,
Info,
Loader2,
} from "lucide-react";
@@ -17,20 +18,22 @@ import {
OnboardingCTAList,
useOnboardingProgress,
} from "..";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { FrostedSurface } from "../../components/tenant";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import {
assignFreeTenantPackage,
createTenantPaddleCheckout,
} from "../../api";
import { cn } from "@/lib/utils";
type PaddleCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
className?: string;
};
function useLocaleFormats(locale: string) {
@@ -69,7 +72,7 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
.join(" ");
}
function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
function PaddleCheckout({ packageId, onSuccess, t, className }: PaddleCheckoutProps) {
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const [error, setError] = React.useState<string | null>(null);
@@ -89,14 +92,32 @@ function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
}, [packageId, onSuccess, t]);
return (
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
<p className="text-sm font-medium text-brand-slate">{t('summary.paddle.heading')}</p>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<FrostedSurface
className={cn(
"space-y-4 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100",
className
)}
>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-400 dark:text-rose-200">
{t('summary.paddle.sectionTitle')}
</p>
<p className="text-base font-semibold text-slate-900 dark:text-slate-100">
{t('summary.paddle.heading')}
</p>
</div>
{error ? (
<div
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">{t('summary.paddle.errorTitle')}</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">{error}</p>
</div>
</div>
) : null}
<Button
size="lg"
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
@@ -115,8 +136,8 @@ function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
</>
)}
</Button>
<p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{t('summary.paddle.hint')}</p>
</FrostedSurface>
);
}
@@ -216,48 +237,83 @@ export default function WelcomeOrderSummaryPage() {
icon={Receipt}
>
{packagesState.status === "loading" && (
<div className="flex items-center gap-3 rounded-2xl bg-slate-50 p-6 text-sm text-brand-navy/80">
<CreditCard className="size-5 text-brand-rose" />
<FrostedSurface className="flex items-center gap-3 rounded-2xl border border-white/20 p-6 text-sm text-slate-600 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-300">
<Loader2 className="size-5 animate-spin text-rose-400" />
{t("summary.state.loading")}
</div>
</FrostedSurface>
)}
{packagesState.status === "error" && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>{t("summary.state.errorTitle")}</AlertTitle>
<AlertDescription>
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-6 text-sm text-rose-700 shadow-md shadow-rose-200/30 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.errorTitle")}
</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">
{packagesState.message ?? t("summary.state.errorDescription")}
</AlertDescription>
</Alert>
</p>
</div>
</FrostedSurface>
)}
{packagesState.status === "success" && !packageDetails && (
<Alert>
<AlertTitle>{t("summary.state.missingTitle")}</AlertTitle>
<AlertDescription>{t("summary.state.missingDescription")}</AlertDescription>
</Alert>
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-white/20 bg-white/15 p-6 text-sm text-slate-800 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-200"
>
<Info className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.missingTitle")}
</p>
<p className="text-sm text-slate-700/90 dark:text-slate-300/80">
{t("summary.state.missingDescription")}
</p>
</div>
</FrostedSurface>
)}
{packagesState.status === "success" && packageDetails && (
<div className="grid gap-4">
<div className="rounded-3xl border border-brand-rose-soft bg-brand-card p-6 shadow-md shadow-rose-100/40">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">
<FrostedSurface className="relative overflow-hidden rounded-3xl border border-white/20 p-6 text-white shadow-xl shadow-rose-300/25 dark:border-slate-800/70 dark:bg-slate-950/85">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_65%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.3),_transparent_65%)]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/65 via-slate-900/10 to-transparent mix-blend-overlay" />
<div className="relative z-10 flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-3">
<Badge className="w-fit rounded-full border border-white/40 bg-white/20 px-4 py-1 text-[10px] font-semibold uppercase tracking-[0.4em] text-white shadow-inner shadow-white/20">
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
</p>
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
</Badge>
<h3 className="font-display text-2xl font-semibold tracking-tight text-white md:text-3xl">
{packageDetails.name}
</h3>
</div>
{priceText && (
<Badge className="rounded-full bg-rose-100 px-4 py-2 text-base font-semibold text-rose-600">
<div className="flex flex-col items-start gap-2 text-left sm:items-end sm:text-right">
{priceText ? (
<Badge className="rounded-full border border-white/40 bg-white/15 px-4 py-2 text-base font-semibold text-white shadow-inner shadow-white/20">
{priceText}
</Badge>
)}
) : null}
<span className="text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</span>
</div>
<dl className="mt-6 grid gap-3 text-sm text-brand-navy/80 md:grid-cols-2">
<div>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.photosTitle")}</dt>
</div>
<dl className="grid gap-4 text-sm text-white/85 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.photosTitle")}
</dt>
<dd>
{packageDetails.max_photos
? t("summary.details.section.photosValue", {
@@ -267,50 +323,91 @@ export default function WelcomeOrderSummaryPage() {
: t("summary.details.section.photosUnlimited")}
</dd>
</div>
<div>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.guestsTitle")}</dt>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.guestsTitle")}
</dt>
<dd>
{packageDetails.max_guests
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
: t("summary.details.section.guestsUnlimited")}
</dd>
</div>
<div>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.featuresTitle")}</dt>
<dd>{featuresList.length ? featuresList.join(", ") : t("summary.details.section.featuresNone")}</dd>
</div>
<div>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.statusTitle")}</dt>
<dd>{activePackage ? t("summary.details.section.statusActive") : t("summary.details.section.statusInactive")}</dd>
</div>
</dl>
{detailBadges.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-wide text-brand-rose">
{detailBadges.map((badge) => (
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
{badge}
</span>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.featuresTitle")}
</dt>
<dd>
{featuresList.length ? (
<div className="flex flex-wrap gap-2">
{featuresList.map((feature) => (
<Badge
key={feature}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{feature}
</Badge>
))}
</div>
) : (
<span className="text-white/70">{t("summary.details.section.featuresNone")}</span>
)}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.statusTitle")}
</dt>
<dd>
<Badge className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</Badge>
</dd>
</div>
</dl>
{detailBadges.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detailBadges.map((badge) => (
<Badge
key={badge}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{badge}
</Badge>
))}
</div>
) : null}
</div>
</FrostedSurface>
{!activePackage && (
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
<ShieldCheck className="size-4" />
<AlertTitle>{t("summary.status.pendingTitle")}</AlertTitle>
<AlertDescription>{t("summary.status.pendingDescription")}</AlertDescription>
</Alert>
<FrostedSurface
role="status"
className="flex items-start gap-3 rounded-2xl border border-amber-200/40 bg-amber-100/15 p-5 text-sm text-amber-800 shadow-md shadow-amber-200/20 dark:border-amber-300/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<ShieldCheck className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.status.pendingTitle")}
</p>
<p className="text-sm text-amber-700/90 dark:text-amber-100/80">
{t("summary.status.pendingDescription")}
</p>
</div>
</FrostedSurface>
)}
{packageDetails.price === 0 && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
<p className="text-sm text-emerald-700">{t("summary.free.description")}</p>
<FrostedSurface className="space-y-3 rounded-2xl border border-emerald-200/40 bg-emerald-100/15 p-6 text-sm text-emerald-900 shadow-md shadow-emerald-200/20 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-100">
<p>{t("summary.free.description")}</p>
{freeAssignStatus === "success" ? (
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
<AlertTitle>{t("summary.free.successTitle")}</AlertTitle>
<AlertDescription>{t("summary.free.successDescription")}</AlertDescription>
</Alert>
<div className="space-y-1 rounded-xl border border-emerald-200/60 bg-emerald-50/30 p-4 text-sm text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-500/20 dark:text-emerald-50">
<p className="font-semibold">{t("summary.free.successTitle")}</p>
<p>{t("summary.free.successDescription")}</p>
</div>
) : (
<Button
onClick={async () => {
@@ -333,7 +430,7 @@ export default function WelcomeOrderSummaryPage() {
}
}}
disabled={freeAssignStatus === "loading"}
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#34d399] via-[#10b981] to-[#059669] text-white shadow-lg shadow-emerald-300/30 hover:from-[#22c783] hover:via-[#0fa776] hover:to-[#047857]"
>
{freeAssignStatus === "loading" ? (
<>
@@ -345,18 +442,19 @@ export default function WelcomeOrderSummaryPage() {
)}
</Button>
)}
{freeAssignStatus === "error" && freeAssignError && (
<Alert variant="destructive" className="mt-3">
<AlertTitle>{t("summary.free.failureTitle")}</AlertTitle>
<AlertDescription>{freeAssignError}</AlertDescription>
</Alert>
)}
{freeAssignStatus === "error" && freeAssignError ? (
<div
role="alert"
className="rounded-xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<p className="font-semibold">{t("summary.free.failureTitle")}</p>
<p>{freeAssignError}</p>
</div>
) : null}
</FrostedSurface>
)}
{requiresPayment && (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
<PaddleCheckout
packageId={packageDetails.id}
onSuccess={() => {
@@ -365,17 +463,16 @@ export default function WelcomeOrderSummaryPage() {
}}
t={t}
/>
</div>
)}
<div className="flex flex-col gap-3 rounded-2xl bg-brand-card p-6 shadow-inner shadow-rose-100/40">
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.nextStepsTitle")}</h4>
<ol className="list-decimal space-y-2 pl-5 text-sm text-brand-navy/80">
<FrostedSurface className="flex flex-col gap-3 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-inner shadow-rose-200/10 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<h4 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{t("summary.nextStepsTitle")}</h4>
<ol className="list-decimal space-y-2 pl-5 text-sm text-slate-600 dark:text-slate-300">
{nextSteps.map((step, index) => (
<li key={`${step}-${index}`}>{step}</li>
))}
</ol>
</div>
</FrostedSurface>
</div>
)}
</WelcomeStepCard>

View File

@@ -11,6 +11,7 @@ import {
} from "..";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { FrostedSurface } from "../../components/tenant/frosted-surface";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import { Package } from "../../api";
@@ -115,16 +116,16 @@ export default function WelcomePackagesPage() {
const renderPackageList = () => {
if (packagesState.status === "loading") {
return (
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
<Loader2 className="size-5 animate-spin text-brand-rose" />
<FrostedSurface className="flex items-center gap-3 border border-white/20 p-6 text-sm text-slate-600 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-300">
<Loader2 className="size-5 animate-spin text-rose-400" />
{t("packages.state.loading")}
</div>
</FrostedSurface>
);
}
if (packagesState.status === "error") {
return (
<Alert variant="destructive">
<Alert variant="destructive" className="border-rose-300/60 bg-rose-50/80 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/15 dark:text-rose-200">
<AlertCircle className="size-4" />
<AlertTitle>{t("packages.state.errorTitle")}</AlertTitle>
<AlertDescription>{packagesState.message ?? t("packages.state.errorDescription")}</AlertDescription>
@@ -134,7 +135,7 @@ export default function WelcomePackagesPage() {
if (packagesState.status === "success" && packagesState.catalog.length === 0) {
return (
<Alert variant="default" className="border-brand-rose-soft bg-brand-card/60 text-brand-navy">
<Alert variant="default" className="border-white/20 bg-white/70 text-slate-700 shadow-sm dark:border-slate-800/60 dark:bg-slate-950/80 dark:text-slate-300">
<AlertTitle>{t("packages.state.emptyTitle")}</AlertTitle>
<AlertDescription>{t("packages.state.emptyDescription")}</AlertDescription>
</Alert>
@@ -170,59 +171,70 @@ export default function WelcomePackagesPage() {
}
return (
<div
<FrostedSurface
key={pkg.id}
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
className="flex flex-col gap-4 border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 transition-colors duration-200 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-rose-400 dark:text-rose-200">
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
</p>
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{pkg.name}</h3>
</div>
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
<span className="text-lg font-medium text-rose-400 dark:text-rose-200">{priceText}</span>
</div>
<p className="text-sm text-brand-navy/80">
<p className="text-sm text-slate-600 dark:text-slate-300">
{pkg.max_photos
? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos })
: t("packages.card.description")}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-rose-400 dark:text-rose-200">
{badges.map((badge) => (
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
<span key={badge} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{badge}
</span>
))}
{featureLabels.map((feature) => (
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
<span key={feature} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{feature}
</span>
))}
{isActive && (
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">
{isActive ? (
<span className="rounded-full bg-sky-100/80 px-3 py-1 text-sky-600 shadow-inner shadow-sky-200/40 dark:bg-sky-500/20 dark:text-sky-200">
{t("packages.card.active")}
</span>
)}
) : null}
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
size="lg"
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => handleSelectPackage(pkg)}
disabled={isActive}
>
{t("packages.card.select")}
{isActive ? t("packages.card.active") : t("packages.card.select")}
<ArrowRight className="ml-2 size-4" />
</Button>
{purchased && (
<p className="text-xs text-brand-rose">
{purchased ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t("packages.card.purchased", {
date: purchased.purchased_at
? dateFormatter.format(new Date(purchased.purchased_at))
: t("packages.card.purchasedUnknown"),
})}
</p>
)}
) : null}
<Button
variant="ghost"
className="text-sm text-slate-600 hover:text-rose-400 dark:text-slate-400 dark:hover:text-rose-200"
onClick={() => navigate(ADMIN_BILLING_PATH)}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("packages.card.viewBilling")}
</Button>
</div>
</FrostedSurface>
);
})}
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { fetchOnboardingStatus, trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
export type OnboardingProgress = {
welcomeSeen: boolean;
@@ -13,6 +14,8 @@ export type OnboardingProgress = {
priceText?: string | null;
isSubscription?: boolean;
} | null;
inviteCreated: boolean;
brandingConfigured: boolean;
};
type OnboardingUpdate = Partial<OnboardingProgress> & {
@@ -34,6 +37,8 @@ const DEFAULT_PROGRESS: OnboardingProgress = {
lastStep: null,
adminAppOpenedAt: null,
selectedPackage: null,
inviteCreated: false,
brandingConfigured: false,
};
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
@@ -74,13 +79,27 @@ function writeStoredProgress(progress: OnboardingProgress) {
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
const [synced, setSynced] = React.useState(false);
const { status } = useAuth();
React.useEffect(() => {
if (status !== 'authenticated') {
if (synced) {
setSynced(false);
}
return;
}
if (synced) {
return;
}
let cancelled = false;
fetchOnboardingStatus().then((status) => {
if (cancelled) {
return;
}
if (!status) {
setSynced(true);
return;
@@ -92,6 +111,8 @@ export function OnboardingProgressProvider({ children }: { children: React.React
adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected),
inviteCreated: Boolean(status.steps.invite_created ?? prev.inviteCreated),
brandingConfigured: Boolean(status.steps.branding_completed ?? prev.brandingConfigured),
};
writeStoredProgress(next);
@@ -110,8 +131,16 @@ export function OnboardingProgressProvider({ children }: { children: React.React
}
setSynced(true);
}).catch(() => {
if (!cancelled) {
setSynced(true);
}
});
}, [synced]);
return () => {
cancelled = true;
};
}, [status, synced]);
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
setProgressState((prev) => {
@@ -124,11 +153,39 @@ export function OnboardingProgressProvider({ children }: { children: React.React
const markStep = React.useCallback((step: OnboardingUpdate) => {
const { serverStep, meta, ...rest } = step;
setProgress((prev) => ({
setProgress((prev) => {
const derived: Partial<OnboardingProgress> = {};
switch (serverStep) {
case 'package_selected':
derived.packageSelected = true;
break;
case 'event_created':
derived.eventCreated = true;
break;
case 'invite_created':
derived.inviteCreated = true;
break;
case 'branding_configured':
derived.brandingConfigured = true;
break;
default:
break;
}
const next: OnboardingProgress = {
...prev,
...rest,
...derived,
lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep,
}));
};
if (serverStep === 'admin_app_opened' && !next.adminAppOpenedAt) {
next.adminAppOpenedAt = new Date().toISOString();
}
return next;
});
if (serverStep) {
trackOnboarding(serverStep, meta).catch(() => {});

View File

@@ -1,16 +1,17 @@
import React from 'react';
import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-react';
import { AlertTriangle, Loader2, RefreshCw, Sparkles, ArrowUpRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout';
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
import { isAuthError } from '../auth/tokens';
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
@@ -107,24 +108,78 @@ export default function BillingPage() {
void loadAll();
}, [loadAll]);
const actions = (
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('billing.actions.refresh')}
</Button>
);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
[activePackage, t, formatDate],
);
return (
<AdminLayout
title={t('billing.title')}
subtitle={t('billing.subtitle')}
actions={actions}
const heroBadge = t('billing.hero.badge', 'Abrechnung');
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
const heroSupporting: string[] = [
activePackage
? t('billing.hero.summary.active', 'Aktives Paket: {{name}}', { name: activePackage.package_name })
: t('billing.hero.summary.inactive', 'Noch kein aktives Paket wählt ein Kontingent, das zu euch passt.'),
t('billing.hero.summary.transactions', '{{count}} Zahlungen synchronisiert', { count: transactions.length })
];
const packagesHref = `/${i18n.language?.split('-')[0] ?? 'de'}/packages`;
const heroPrimaryAction = (
<Button
size="sm"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => void loadAll()}
disabled={loading}
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('billing.actions.refresh')}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
variant="outline"
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
onClick={() => window.location.assign(packagesHref)}
>
{t('billing.actions.explorePackages', 'Pakete vergleichen')}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
);
const nextRenewalLabel = t('billing.hero.nextRenewal', 'Verlängerung am');
const topWarning = activeWarnings[0];
const heroAside = (
<FrostedSurface className="space-y-4 border-white/25 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/85">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{t('billing.hero.activePackage', 'Aktuelles Paket')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{activePackage?.package_name ?? t('billing.hero.activeFallback', 'Noch nicht ausgewählt')}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{nextRenewalLabel}</p>
<p className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-200">{formatDate(activePackage?.expires_at)}</p>
</div>
{topWarning ? (
<div className="rounded-xl border border-amber-200/60 bg-amber-50/80 p-3 text-xs text-amber-800 shadow-inner shadow-amber-200/40 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200">
{topWarning.message}
</div>
) : null}
</FrostedSurface>
);
return (
<AdminLayout title={t('billing.title')} subtitle={t('billing.subtitle')}>
<TenantHeroCard
badge={heroBadge}
title={t('billing.title')}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
@@ -136,7 +191,7 @@ export default function BillingPage() {
<BillingSkeleton />
) : (
<>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<FrostedCard className="mt-6 border border-white/20">
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
@@ -147,7 +202,7 @@ export default function BillingPage() {
{t('billing.sections.overview.description')}
</CardDescription>
</div>
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700 dark:bg-pink-500/20 dark:text-pink-200' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
</Badge>
</CardHeader>
@@ -160,7 +215,7 @@ export default function BillingPage() {
<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}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900 dark:border-amber-500/60 dark:bg-amber-500/15 dark:text-amber-200' : 'dark:border-slate-800/70 dark:bg-slate-950/80'}
>
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
@@ -204,9 +259,9 @@ export default function BillingPage() {
<EmptyState message={t('billing.sections.overview.empty')} />
)}
</CardContent>
</Card>
</FrostedCard>
<Card className="border-0 bg-white/85 shadow-xl shadow-amber-100/60">
<FrostedCard className="border border-white/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-amber-500" />
@@ -236,9 +291,9 @@ export default function BillingPage() {
})
)}
</CardContent>
</Card>
</FrostedCard>
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<FrostedCard className="border border-white/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-sky-500" />
@@ -282,7 +337,7 @@ export default function BillingPage() {
</Button>
)}
</CardContent>
</Card>
</FrostedCard>
</>
)}
@@ -322,47 +377,47 @@ function TransactionCard({
});
return (
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<FrostedSurface className="flex flex-col gap-3 border border-slate-200/60 p-4 text-slate-900 shadow-md shadow-slate-200/10 transition-colors duration-200 dark:border-slate-800/70 dark:bg-slate-950/80 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-800">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
</p>
<p className="text-xs uppercase tracking-wide text-slate-500">{createdLabel}</p>
{transaction.checkout_id && (
<p className="text-xs text-slate-500">
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{createdLabel}</p>
{transaction.checkout_id ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
</p>
)}
{transaction.origin && (
<p className="text-xs text-slate-500">
) : null}
{transaction.origin ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
</p>
)}
) : null}
</div>
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 md:flex-row md:items-center md:gap-4">
<Badge className="bg-sky-100 text-sky-700">
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 md:flex-row md:items-center md:gap-4">
<Badge className="bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200">
{statusText}
</Badge>
<div className="text-base font-semibold text-slate-900">
<div className="text-base font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(amount, currency)}
</div>
{transaction.tax !== undefined && transaction.tax !== null && (
<span className="text-xs text-slate-500">
{transaction.tax !== undefined && transaction.tax !== null ? (
<span className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
</span>
)}
{transaction.receipt_url && (
) : null}
{transaction.receipt_url ? (
<a
href={transaction.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-sky-600 hover:text-sky-700"
className="text-xs font-medium text-sky-600 transition hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
>
{t('billing.sections.transactions.labels.receipt')}
</a>
)}
</div>
) : null}
</div>
</FrostedSurface>
);
}
@@ -377,19 +432,19 @@ function InfoCard({
helper?: string;
tone: 'pink' | 'amber' | 'sky' | 'emerald';
}) {
const toneClass = {
pink: 'from-pink-50 to-rose-100 text-pink-700',
amber: 'from-amber-50 to-yellow-100 text-amber-700',
sky: 'from-sky-50 to-blue-100 text-sky-700',
emerald: 'from-emerald-50 to-green-100 text-emerald-700',
}[tone];
const toneBorders: Record<'pink' | 'amber' | 'sky' | 'emerald', string> = {
pink: 'border-pink-200/60 shadow-rose-200/30',
amber: 'border-amber-200/60 shadow-amber-200/30',
sky: 'border-sky-200/60 shadow-sky-200/30',
emerald: 'border-emerald-200/60 shadow-emerald-200/30',
} as const;
return (
<div className={`rounded-2xl border border-white/60 bg-gradient-to-br ${toneClass} p-5 shadow-sm`}>
<span className="text-xs uppercase tracking-wide text-slate-600/90">{label}</span>
<FrostedSurface className={`border ${toneBorders[tone]} p-5 text-slate-900 shadow-md transition-colors duration-200 dark:text-slate-100`}>
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
<div className="mt-3 text-xl font-semibold">{value ?? '--'}</div>
{helper && <p className="mt-2 text-xs text-slate-600/80">{helper}</p>}
</div>
{helper ? <p className="mt-2 text-xs text-slate-600 dark:text-slate-400">{helper}</p> : null}
</FrostedSurface>
);
}
@@ -415,14 +470,14 @@ function PackageCard({
warnings?: PackageWarning[];
}) {
return (
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
<FrostedSurface className={`space-y-4 border ${isActive ? 'border-amber-200/60 shadow-amber-200/20' : 'border-slate-200/60 shadow-slate-200/20'} p-5 text-slate-900 dark:text-slate-100`}>
{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}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200' : 'dark:border-slate-800/70 dark:bg-slate-950/80'}
>
<AlertDescription className="flex items-center gap-2 text-xs">
<AlertTriangle className="h-4 w-4" />
@@ -434,17 +489,17 @@ function PackageCard({
)}
<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>
<p className="text-xs text-slate-600">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{pkg.package_name}</h3>
<p className="text-xs text-slate-600 dark:text-slate-400">
{formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}
</p>
</div>
<Badge className={isActive ? 'bg-amber-500/10 text-amber-700' : 'bg-slate-200 text-slate-700'}>
<Badge className={isActive ? 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{isActive ? labels.statusActive : labels.statusInactive}
</Badge>
</div>
<Separator className="my-3" />
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-3">
<Separator className="my-3 dark:border-slate-800/70" />
<div className="grid gap-2 text-xs text-slate-600 dark:text-slate-400 sm:grid-cols-3">
<span>
{labels.used}: {pkg.used_events}
</span>
@@ -455,18 +510,18 @@ function PackageCard({
{labels.expires}: {formatDate(pkg.expires_at)}
</span>
</div>
</div>
</FrostedSurface>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-8 text-center">
<FrostedSurface className="flex flex-col items-center justify-center gap-3 border border-dashed border-slate-200/70 p-8 text-center shadow-inner dark:border-slate-700/60">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Sparkles className="h-5 w-5" />
</div>
<p className="text-sm text-slate-600">{message}</p>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{message}</p>
</FrostedSurface>
);
}
@@ -528,17 +583,20 @@ function BillingSkeleton() {
return (
<div className="grid gap-6">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-4 rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm">
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
<FrostedSurface
key={index}
className="space-y-4 border border-white/20 p-6 shadow-md shadow-rose-200/10 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/30 via-white/60 to-white/30 dark:from-slate-700 dark:via-slate-600 dark:to-slate-700" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((__, placeholderIndex) => (
<div
key={placeholderIndex}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/30 via-white/55 to-white/30 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800"
/>
))}
</div>
</div>
</FrostedSurface>
))}
</div>
);

View File

@@ -9,12 +9,10 @@ import {
Users,
Plus,
Settings,
CheckCircle2,
Circle,
QrCode,
ClipboardList,
Package as PackageIcon,
Loader2,
ArrowUpRight,
} from 'lucide-react';
import toast from 'react-hot-toast';
@@ -22,6 +20,9 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { TenantHeroCard, TenantOnboardingChecklistCard, FrostedSurface } from '../components/tenant';
import type { ChecklistStep } from '../components/tenant';
import { AdminLayout } from '../components/AdminLayout';
import {
@@ -36,9 +37,11 @@ import { isAuthError } from '../auth/tokens';
import { useAuth } from '../auth/context';
import {
adminPath,
ADMIN_HOME_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
@@ -47,6 +50,7 @@ import {
} from '../constants';
import { useOnboardingProgress } from '../onboarding';
import { buildLimitWarnings } from '../lib/limitWarnings';
import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings';
interface DashboardState {
summary: DashboardSummary | null;
@@ -75,12 +79,23 @@ export default function DashboardPage() {
const { t: tc } = useTranslation('common');
const translate = React.useCallback(
(key: string, options?: Record<string, unknown>) => {
const value = t(key, options);
(key: string, optionsOrFallback?: Record<string, unknown> | string, explicitFallback?: string) => {
const hasOptions = typeof optionsOrFallback === 'object' && optionsOrFallback !== null;
const options = hasOptions ? (optionsOrFallback as Record<string, unknown>) : undefined;
const fallback = typeof optionsOrFallback === 'string' ? optionsOrFallback : explicitFallback;
const value = t(key, { defaultValue: fallback, ...(options ?? {}) });
if (value === `dashboard.${key}`) {
const fallback = i18n.t(`dashboard:${key}`, options);
return fallback === `dashboard:${key}` ? value : fallback;
const fallbackValue = i18n.t(`dashboard:${key}`, { defaultValue: fallback, ...(options ?? {}) });
if (fallbackValue !== `dashboard:${key}`) {
return fallbackValue;
}
if (fallback !== undefined) {
return fallback;
}
}
return value;
},
[t, i18n],
@@ -237,6 +252,191 @@ export default function DashboardPage() {
[tc],
);
const hasPhotos = React.useMemo(() => {
if ((summary?.new_photos ?? 0) > 0) {
return true;
}
return events.some((event) => Number(event.photo_count ?? 0) > 0 || Number(event.pending_photo_count ?? 0) > 0);
}, [summary, events]);
const primaryEventSlug = readiness.primaryEventSlug;
const onboardingChecklist = React.useMemo<ChecklistStep[]>(() => {
const steps: ChecklistStep[] = [
{
key: 'admin_app',
title: translate('onboarding.admin_app.title', 'Admin-App öffnen'),
description: translate(
'onboarding.admin_app.description',
'Verwalte Events, Uploads und Gäste direkt in der Admin-App.'
),
done: Boolean(progress.adminAppOpenedAt),
ctaLabel: translate('onboarding.admin_app.cta', 'Admin-App starten'),
onAction: () => navigate(ADMIN_HOME_PATH),
icon: Sparkles,
},
{
key: 'event_setup',
title: translate('onboarding.event_setup.title', 'Erstes Event vorbereiten'),
description: translate(
'onboarding.event_setup.description',
'Lege in der Admin-App Name, Datum und Aufgaben fest.'
),
done: readiness.hasEvent,
ctaLabel: translate('onboarding.event_setup.cta', 'Event anlegen'),
onAction: () => navigate(ADMIN_EVENT_CREATE_PATH),
icon: CalendarDays,
},
{
key: 'invite_guests',
title: translate('onboarding.invite_guests.title', 'Gäste einladen'),
description: translate(
'onboarding.invite_guests.description',
'Teile QR-Codes oder Links, damit Gäste sofort starten.'
),
done: readiness.hasQrInvites || progress.inviteCreated,
ctaLabel: translate('onboarding.invite_guests.cta', 'QR-Links öffnen'),
onAction: () => {
if (primaryEventSlug) {
navigate(`${ADMIN_EVENT_VIEW_PATH(primaryEventSlug)}#qr-invites`);
return;
}
navigate(ADMIN_EVENTS_PATH);
},
icon: QrCode,
},
{
key: 'collect_photos',
title: translate('onboarding.collect_photos.title', 'Erste Fotos einsammeln'),
description: translate(
'onboarding.collect_photos.description',
'Sobald Uploads eintreffen, moderierst du sie in der Admin-App.'
),
done: hasPhotos,
ctaLabel: translate('onboarding.collect_photos.cta', 'Uploads prüfen'),
onAction: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
icon: Camera,
},
{
key: 'branding',
title: translate('onboarding.branding.title', 'Branding & Aufgaben verfeinern'),
description: translate(
'onboarding.branding.description',
'Passt Farbwelt und Aufgabenpakete an euren Anlass an.'
),
done: (progress.brandingConfigured || readiness.hasTasks) && (readiness.hasPackage || progress.packageSelected),
ctaLabel: translate('onboarding.branding.cta', 'Branding öffnen'),
onAction: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
icon: ClipboardList,
},
];
return steps;
}, [
translate,
progress.adminAppOpenedAt,
progress.inviteCreated,
progress.brandingConfigured,
progress.packageSelected,
readiness.hasEvent,
readiness.hasQrInvites,
readiness.hasTasks,
readiness.hasPackage,
hasPhotos,
navigate,
primaryEventSlug,
]);
const completedOnboardingSteps = React.useMemo(
() => onboardingChecklist.filter((step) => step.done).length,
[onboardingChecklist]
);
const onboardingCompletion = React.useMemo(() => {
if (onboardingChecklist.length === 0) {
return 0;
}
return Math.round((completedOnboardingSteps / onboardingChecklist.length) * 100);
}, [completedOnboardingSteps, onboardingChecklist]);
const onboardingCardTitle = translate('onboarding.card.title', 'Dein Start in fünf Schritten');
const onboardingCardDescription = translate(
'onboarding.card.description',
'Bearbeite die Schritte in der Admin-App das Dashboard zeigt dir den Status.'
);
const onboardingCompletedCopy = translate(
'onboarding.card.completed',
'Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.'
);
const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten');
const heroBadge = translate('overview.title', 'Kurzer Überblick');
const heroDescription = translate(
'overview.description',
'Wichtigste Kennzahlen deines Tenants auf einen Blick.'
);
const marketingDashboardLabel = translate('onboarding.back_to_marketing', 'Marketing-Dashboard ansehen');
const marketingDashboardDescription = translate(
'onboarding.back_to_marketing_description',
'Zur Zusammenfassung im Kundenportal wechseln.'
);
const heroSupportingCopy = onboardingCompletion === 100 ? onboardingCompletedCopy : onboardingCardDescription;
const heroPrimaryCtaLabel = readiness.hasEvent
? translate('quickActions.moderatePhotos.label', 'Fotos moderieren')
: translate('actions.newEvent');
const heroPrimaryAction = (
<Button
size="sm"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => {
if (readiness.hasEvent) {
navigate(ADMIN_EVENTS_PATH);
} else {
navigate(ADMIN_EVENT_CREATE_PATH);
}
}}
>
{heroPrimaryCtaLabel}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
variant="outline"
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
onClick={() => window.location.assign('/dashboard')}
>
{marketingDashboardLabel}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
);
const heroAside = (
<FrostedSurface className="w-full rounded-2xl border-white/30 bg-white/90 p-5 text-slate-900 shadow-lg shadow-rose-300/20 backdrop-blur">
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
<span>{onboardingCardTitle}</span>
<span>
{completedOnboardingSteps}/{onboardingChecklist.length}
</span>
</div>
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
</FrostedSurface>
);
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const actions = (
<>
<Button
@@ -273,6 +473,16 @@ export default function DashboardPage() {
<DashboardSkeleton />
) : (
<>
<TenantHeroCard
badge={heroBadge}
title={greetingTitle}
description={subtitle}
supporting={[heroDescription, heroSupportingCopy]}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{events.length === 0 && (
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="space-y-3">
@@ -446,53 +656,26 @@ export default function DashboardPage() {
description={translate('quickActions.managePackages.description')}
onClick={() => navigate(ADMIN_BILLING_PATH)}
/>
<QuickAction
icon={<ArrowUpRight className="h-5 w-5" />}
label={marketingDashboardLabel}
description={marketingDashboardDescription}
onClick={() => window.location.assign('/dashboard')}
/>
</CardContent>
</Card>
<ReadinessCard
readiness={readiness}
labels={{
title: translate('readiness.title'),
description: translate('readiness.description'),
pending: translate('readiness.pending'),
complete: translate('readiness.complete'),
items: {
event: {
title: translate('readiness.items.event.title'),
hint: translate('readiness.items.event.hint'),
},
tasks: {
title: translate('readiness.items.tasks.title'),
hint: translate('readiness.items.tasks.hint'),
},
qr: {
title: translate('readiness.items.qr.title'),
hint: translate('readiness.items.qr.hint'),
},
package: {
title: translate('readiness.items.package.title'),
hint: translate('readiness.items.package.hint'),
},
},
actions: {
createEvent: translate('readiness.actions.createEvent'),
openTasks: translate('readiness.actions.openTasks'),
openQr: translate('readiness.actions.openQr'),
openPackages: translate('readiness.actions.openPackages'),
},
}}
onCreateEvent={() => navigate(ADMIN_EVENT_CREATE_PATH)}
onOpenTasks={() =>
readiness.primaryEventSlug
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
: navigate(buildEngagementTabPath('tasks'))
}
onOpenQr={() =>
readiness.primaryEventSlug
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
: navigate(ADMIN_EVENTS_PATH)
}
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
<TenantOnboardingChecklistCard
title={onboardingCardTitle}
description={onboardingCardDescription}
steps={onboardingChecklist}
completedLabel={readinessCompleteLabel}
pendingLabel={readinessPendingLabel}
completionPercent={onboardingCompletion}
completedCount={completedOnboardingSteps}
totalCount={onboardingChecklist.length}
emptyCopy={onboardingCompletedCopy}
fallbackActionLabel={onboardingFallbackCta}
/>
<Card className="border-0 bg-brand-card shadow-brand-primary">
@@ -619,25 +802,6 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
.slice(0, 4);
}
type ReadinessLabels = {
title: string;
description: string;
pending: string;
complete: string;
items: {
event: { title: string; hint: string };
tasks: { title: string; hint: string };
qr: { title: string; hint: string };
package: { title: string; hint: string };
};
actions: {
createEvent: string;
openTasks: string;
openQr: string;
openPackages: string;
};
};
function LimitUsageRow({
label,
summary,
@@ -656,9 +820,9 @@ function LimitUsageRow({
<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>
<span className="text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</span>
</div>
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
</div>
);
}
@@ -674,23 +838,23 @@ function LimitUsageRow({
: '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">
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800/70 dark:bg-slate-950/80">
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
<span>{label}</span>
<span className="text-xs text-slate-500">
<span className="text-xs text-slate-500 dark:text-slate-400">
{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="mt-3 h-2 rounded-full bg-slate-200 dark:bg-slate-800">
<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">
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{remainingLabel
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
.replace('{{limit}}', `${limit}`)}
@@ -698,7 +862,7 @@ function LimitUsageRow({
) : null}
</>
) : (
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
)}
</div>
);
@@ -732,155 +896,10 @@ function GalleryStatusRow({
}
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">
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800/70 dark:bg-slate-950/80">
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
<span>{label}</span>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass}`}>{statusLabel}</span>
</div>
</div>
);
}
function ReadinessCard({
readiness,
labels,
onCreateEvent,
onOpenTasks,
onOpenQr,
onOpenPackages,
}: {
readiness: ReadinessState;
labels: ReadinessLabels;
onCreateEvent: () => void;
onOpenTasks: () => void;
onOpenQr: () => void;
onOpenPackages: () => void;
}) {
const checklistItems = [
{
key: 'event',
icon: <CalendarDays className="h-5 w-5" />,
completed: readiness.hasEvent,
label: labels.items.event.title,
hint: labels.items.event.hint,
actionLabel: labels.actions.createEvent,
onAction: onCreateEvent,
showAction: !readiness.hasEvent,
},
{
key: 'tasks',
icon: <ClipboardList className="h-5 w-5" />,
completed: readiness.hasTasks,
label: labels.items.tasks.title,
hint: labels.items.tasks.hint,
actionLabel: labels.actions.openTasks,
onAction: onOpenTasks,
showAction: readiness.hasEvent && !readiness.hasTasks,
},
{
key: 'qr',
icon: <QrCode className="h-5 w-5" />,
completed: readiness.hasQrInvites,
label: labels.items.qr.title,
hint: labels.items.qr.hint,
actionLabel: labels.actions.openQr,
onAction: onOpenQr,
showAction: readiness.hasEvent && !readiness.hasQrInvites,
},
{
key: 'package',
icon: <PackageIcon className="h-5 w-5" />,
completed: readiness.hasPackage,
label: labels.items.package.title,
hint: labels.items.package.hint,
actionLabel: labels.actions.openPackages,
onAction: onOpenPackages,
showAction: !readiness.hasPackage,
},
] as const;
const activeEventName = readiness.primaryEventName;
return (
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="space-y-2">
<CardTitle className="text-xl text-slate-900">{labels.title}</CardTitle>
<CardDescription className="text-sm text-slate-600">{labels.description}</CardDescription>
{activeEventName ? (
<p className="text-xs uppercase tracking-wide text-brand-rose-soft">
{activeEventName}
</p>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{readiness.loading ? (
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/70 px-4 py-3 text-xs text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.pending}
</div>
) : (
checklistItems.map((item) => (
<ChecklistRow
key={item.key}
icon={item.icon}
label={item.label}
hint={item.hint}
completed={item.completed}
status={{ complete: labels.complete, pending: labels.pending }}
action={
item.showAction
? {
label: item.actionLabel,
onClick: item.onAction,
disabled:
(item.key === 'tasks' || item.key === 'qr') && !readiness.primaryEventSlug,
}
: undefined
}
/>
))
)}
</CardContent>
</Card>
);
}
function ChecklistRow({
icon,
label,
hint,
completed,
status,
action,
}: {
icon: React.ReactNode;
label: string;
hint: string;
completed: boolean;
status: { complete: string; pending: string };
action?: { label: string; onClick: () => void; disabled?: boolean };
}) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft/40 bg-white/85 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${completed ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{label}</p>
<p className="text-xs text-slate-600">{hint}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={`flex items-center gap-1 text-xs font-medium ${completed ? 'text-emerald-600' : 'text-slate-500'}`}>
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{completed ? status.complete : status.pending}
</span>
{action ? (
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
{action.label}
</Button>
) : null}
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass} dark:text-slate-100`}>{statusLabel}</span>
</div>
</div>
);
@@ -898,14 +917,14 @@ function StatCard({
icon: React.ReactNode;
}) {
return (
<div className="rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-md shadow-pink-100/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
<FrostedSurface className="border-brand-rose-soft/40 p-5 shadow-md shadow-pink-100/30 transition-transform duration-200 ease-out hover:-translate-y-0.5 hover:shadow-lg hover:shadow-rose-300/30">
<div className="flex items-center justify-between">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
</div>
<div className="mt-4 text-2xl font-semibold text-slate-900">{value}</div>
{hint && <p className="mt-2 text-xs text-slate-500">{hint}</p>}
</div>
<div className="mt-4 text-2xl font-semibold text-slate-900 dark:text-slate-100">{value}</div>
{hint && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{hint}</p>}
</FrostedSurface>
);
}
@@ -924,11 +943,11 @@ function QuickAction({
<button
type="button"
onClick={onClick}
className="flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40"
className="group flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/85 p-4 text-left shadow-sm transition duration-200 ease-out hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
<span className="text-sm font-semibold text-slate-900">{label}</span>
<span className="text-xs text-slate-600">{description}</span>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose shadow-sm shadow-rose-200/60 transition-transform duration-200 group-hover:scale-105">{icon}</span>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{label}</span>
<span className="text-xs text-slate-600 dark:text-slate-400">{description}</span>
</button>
);
}
@@ -1009,4 +1028,3 @@ function DashboardSkeleton() {
</div>
);
}

View File

@@ -41,7 +41,7 @@ export type EmotionsSectionProps = {
embedded?: boolean;
};
export function EmotionsSection({ embedded = false }: EmotionsSectionProps): JSX.Element {
export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const { t, i18n } = useTranslation('management');
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
@@ -191,7 +191,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps): JSX
);
}
export default function EmotionsPage(): JSX.Element {
export default function EmotionsPage() {
const { t } = useTranslation('management');
return (
<AdminLayout title={t('emotions.title')} subtitle={t('emotions.subtitle')}>

View File

@@ -3,8 +3,11 @@ import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { AdminLayout } from '../components/AdminLayout';
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
import { TasksSection } from './TasksPage';
import { TaskCollectionsSection } from './TaskCollectionsPage';
import { EmotionsSection } from './EmotionsPage';
@@ -19,7 +22,7 @@ function ensureValidTab(value: string | null): EngagementTab {
return 'tasks';
}
export default function EngagementPage(): JSX.Element {
export default function EngagementPage() {
const { t } = useTranslation('management');
const { t: tc } = useTranslation('common');
const [searchParams, setSearchParams] = useSearchParams();
@@ -41,37 +44,91 @@ export default function EngagementPage(): JSX.Element {
);
const heading = tc('navigation.engagement');
const heroDescription = t('engagement.hero.description', {
defaultValue: 'Kuratiere Aufgaben, Moderationskollektionen und Emotionen als kreative Toolbox für jedes Event.'
});
const heroSupporting = [
t('engagement.hero.summary.tasks', { defaultValue: 'Plane Aufgaben, die Gäste motivieren von Upload-Regeln bis zu Story-Prompts.' }),
t('engagement.hero.summary.collections', { defaultValue: 'Sammle Vorlagen und kollektive Inhalte, um Events im Handumdrehen neu zu starten.' })
];
const heroPrimaryAction = (
<Button
size="sm"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => handleTabChange('tasks')}
>
{t('engagement.hero.actions.tasks', 'Zu Aufgaben wechseln')}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
variant="outline"
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
onClick={() => handleTabChange('collections')}
>
{t('engagement.hero.actions.collections', 'Kollektionen ansehen')}
</Button>
);
const heroAside = (
<FrostedSurface className="space-y-4 border-white/20 p-5 text-slate-900 shadow-md shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/80">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">{t('engagement.hero.activeTab', { defaultValue: 'Aktiver Bereich' })}</p>
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{t(`engagement.tabs.${activeTab}.title`, { defaultValue: tc(`navigation.${activeTab}`) })}</p>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">
{t('engagement.hero.tip', 'Wechsle Tabs, um Aufgaben, Kollektionen oder Emotionen zu bearbeiten und direkt in Events einzubinden.')}
</p>
</FrostedSurface>
);
return (
<AdminLayout
title={heading}
subtitle={t('engagement.subtitle', 'Bündle Aufgaben, Vorlagen und Emotionen für deine Events.')}
>
<TenantHeroCard
badge={t('engagement.hero.badge', 'Engagement')}
title={heading}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<FrostedCard className="mt-6 border border-white/20">
<CardHeader className="px-0 pt-0">
<CardTitle className="sr-only">{heading}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full grid-cols-3 bg-white/60 shadow-sm">
<TabsTrigger value="tasks">{tc('navigation.tasks')}</TabsTrigger>
<TabsTrigger value="collections">{tc('navigation.collections')}</TabsTrigger>
<TabsTrigger value="emotions">{tc('navigation.emotions')}</TabsTrigger>
<TabsList className="grid w-full grid-cols-3 rounded-2xl border border-white/25 bg-white/80 p-1 shadow-inner shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-900/70">
{(['tasks', 'collections', 'emotions'] as const).map((tab) => (
<TabsTrigger
key={tab}
value={tab}
className="rounded-xl text-sm font-medium transition data-[state=active]:bg-gradient-to-r data-[state=active]:from-[#ff5f87] data-[state=active]:via-[#ec4899] data-[state=active]:to-[#6366f1] data-[state=active]:text-white"
>
{tc(`navigation.${tab}`)}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="tasks" className="space-y-6">
<TasksSection
embedded
onNavigateToCollections={() => handleTabChange('collections')}
/>
<TasksSection embedded onNavigateToCollections={() => handleTabChange('collections')} />
</TabsContent>
<TabsContent value="collections" className="space-y-6">
<TaskCollectionsSection
embedded
onNavigateToTasks={() => handleTabChange('tasks')}
/>
<TaskCollectionsSection embedded onNavigateToTasks={() => handleTabChange('tasks')} />
</TabsContent>
<TabsContent value="emotions" className="space-y-6">
<EmotionsSection embedded />
</TabsContent>
</Tabs>
</CardContent>
</FrostedCard>
</AdminLayout>
);
}

View File

@@ -31,7 +31,7 @@ import {
EventToolkitTask,
TenantEvent,
TenantPhoto,
TenantEventStats,
EventStats,
getEvent,
getEventStats,
getEventToolkit,
@@ -62,13 +62,13 @@ type ToolkitState = {
type WorkspaceState = {
event: TenantEvent | null;
stats: TenantEventStats | null;
stats: EventStats | null;
loading: boolean;
busy: boolean;
error: string | null;
};
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps): JSX.Element {
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
@@ -325,7 +325,7 @@ function resolveName(name: TenantEvent['name']): string {
return 'Event';
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: TenantEventStats | null; busy: boolean; onToggle: () => void }) {
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management');
const statusLabel = event.status === 'published'
@@ -355,12 +355,21 @@ function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stat
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-4 text-xs text-pink-900">
<p className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
<p>
{t('events.workspace.fields.uploadsTotal', { defaultValue: '{{count}} Uploads gesamt', count: stats.uploads_total })}
{t('events.workspace.fields.uploadsTotal', {
defaultValue: '{{count}} Uploads gesamt',
count: stats.uploads_total ?? stats.total ?? 0,
})}
{' · '}
{t('events.workspace.fields.uploadsToday', { defaultValue: '{{count}} Uploads (24h)', count: stats.uploads_24h })}
{t('events.workspace.fields.uploadsToday', {
defaultValue: '{{count}} Uploads (24h)',
count: stats.uploads_24h ?? stats.recent_uploads ?? 0,
})}
</p>
<p>
{t('events.workspace.fields.likesTotal', { defaultValue: '{{count}} Likes vergeben', count: stats.likes_total })}
{t('events.workspace.fields.likesTotal', {
defaultValue: '{{count}} Likes vergeben',
count: stats.likes_total ?? stats.likes ?? 0,
})}
</p>
</div>
)}
@@ -456,7 +465,7 @@ function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; bu
);
}
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: TenantEventStats | null }) {
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: EventStats | null }) {
const { t } = useTranslation('management');
const cards = [
@@ -780,7 +789,8 @@ function resolveEventType(event: TenantEvent): string {
if (typeof event.event_type.name === 'string') {
return event.event_type.name;
}
return event.event_type.name.de ?? event.event_type.name.en ?? Object.values(event.event_type.name)[0] ?? '—';
const translations = event.event_type.name as Record<string, string>;
return translations.de ?? translations.en ?? Object.values(translations)[0] ?? '—';
}
return '—';
}
@@ -801,7 +811,7 @@ function AlertList({ alerts }: { alerts: string[] }) {
);
}
function CalendarIcon(): JSX.Element {
function CalendarIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
@@ -812,7 +822,7 @@ function CalendarIcon(): JSX.Element {
);
}
function WorkspaceSkeleton(): JSX.Element {
function WorkspaceSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
@@ -837,6 +847,6 @@ function WorkspaceSkeleton(): JSX.Element {
);
}
function SkeletonCard(): JSX.Element {
function SkeletonCard() {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}

View File

@@ -2,6 +2,6 @@ import React from 'react';
import EventDetailPage from './EventDetailPage';
export default function EventToolkitPage(): JSX.Element {
export default function EventToolkitPage() {
return <EventDetailPage mode="toolkit" />;
}

View File

@@ -1,11 +1,12 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react';
import { AlertTriangle, ArrowRight, CalendarDays, Plus, Share2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
import { AdminLayout } from '../components/AdminLayout';
import { getEvents, TenantEvent } from '../api';
@@ -28,6 +29,7 @@ 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);
@@ -47,28 +49,107 @@ export default function EventsPage() {
})();
}, []);
const actions = (
<>
<Button
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" /> {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" /> {t('events.list.actions.settings', 'Einstellungen')}
</Button>
</Link>
</>
const translateManagement = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
return (
<AdminLayout
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}
const translateCommon = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
[tCommon],
);
const pageTitle = translateManagement('events.list.title', 'Deine Events');
const pageSubtitle = translateManagement(
'events.list.subtitle',
'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.'
);
const totalEvents = rows.length;
const publishedEvents = React.useMemo(() => rows.filter((event) => event.status === 'published').length, [rows]);
const nextEvent = React.useMemo(() => {
return rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null;
}, [rows]);
const heroDescription = t(
'events.list.hero.description',
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'
);
const heroSummaryCopy = totalEvents > 0
? t('events.list.hero.summary', ':count Events aktiv verwaltet halte Aufgaben und Uploads im Blick.', { count: totalEvents })
: t('events.list.hero.summary_empty', 'Noch keine Events starte jetzt mit deinem ersten Konzept.');
const heroSecondaryCopy = t(
'events.list.hero.secondary',
'Erstelle Events im Admin, begleite Gäste live vor Ort und prüfe Kennzahlen im Marketing-Dashboard.'
);
const heroBadge = t('events.list.badge.dashboard', 'Tenant Dashboard');
const heroPrimaryAction = (
<Button
size="sm"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => navigate(adminPath('/events/new'))}
>
{t('events.list.actions.create', 'Neues Event')}
</Button>
);
const heroSecondaryAction = (
<Button size="sm" variant="outline" className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white" asChild>
<Link to={ADMIN_SETTINGS_PATH}>
{t('events.list.actions.settings', 'Einstellungen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
);
const heroAside = (
<FrostedSurface className="w-full rounded-2xl border-white/30 bg-white/95 p-5 text-slate-900 shadow-lg shadow-pink-200/20 backdrop-blur">
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500">
{t('events.list.hero.published_label', 'Veröffentlichte Events')}
</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">{publishedEvents}</p>
<p className="text-xs text-slate-500">
{t('events.list.hero.total_label', ':count insgesamt', { count: totalEvents })}
</p>
</div>
{nextEvent ? (
<div className="rounded-xl border border-pink-100 bg-pink-50/70 p-4 text-slate-900 shadow-inner shadow-pink-200/50">
<p className="text-xs uppercase tracking-wide text-pink-600">
{t('events.list.hero.next_label', 'Nächstes Event')}
</p>
<p className="mt-1 text-sm font-semibold">{renderName(nextEvent.name)}</p>
<p className="text-xs text-slate-600">{formatDate(nextEvent.event_date)}</p>
{nextEvent.slug ? (
<Button
variant="ghost"
size="sm"
className="mt-3 h-8 justify-start px-3 text-pink-600 hover:bg-pink-100"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(nextEvent.slug))}
>
{t('events.list.hero.open_event', 'Event öffnen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
</div>
) : (
<p className="rounded-xl border border-dashed border-pink-200/70 bg-white/70 p-4 text-xs text-slate-600">
{t('events.list.hero.no_upcoming', 'Plane ein Datum, um hier die nächste Station zu sehen.')}
</p>
)}
</div>
</FrostedSurface>
);
return (
<AdminLayout title={pageTitle} subtitle={pageSubtitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
@@ -76,58 +157,70 @@ export default function EventsPage() {
</Alert>
)}
<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">
<TenantHeroCard
badge={heroBadge}
title={pageTitle}
description={heroDescription}
supporting={[heroSummaryCopy, heroSecondaryCopy]}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<FrostedCard className="mt-6">
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl font-semibold text-slate-900">{t('events.list.overview.title', 'Übersicht')}</CardTitle>
<CardDescription className="text-slate-600">
{rows.length === 0
<CardTitle>{t('events.list.overview.title', 'Übersicht')}</CardTitle>
<CardDescription>
{loading
? t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …')
: totalEvents === 0
? 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 })}
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: totalEvents })}
</CardDescription>
</div>
<div className="flex items-center gap-2 text-sm text-pink-600">
<Sparkles className="h-4 w-4" /> {t('events.list.badge.dashboard', 'Tenant Dashboard')}
</div>
<Badge className="bg-pink-100 text-pink-700">{t('events.list.badge.dashboard', 'Tenant Dashboard')}</Badge>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<LoadingState />
) : rows.length === 0 ? (
) : totalEvents === 0 ? (
<EmptyState onCreate={() => navigate(adminPath('/events/new'))} />
) : (
<div className="space-y-4">
{rows.map((event) => (
<EventCard key={event.id} event={event} translateCommon={tCommon} />
<EventCard key={event.id} event={event} translate={translateManagement} translateCommon={translateCommon} />
))}
</div>
)}
</CardContent>
</Card>
</FrostedCard>
</AdminLayout>
);
}
function EventCard({
event,
translate,
translateCommon,
}: {
event: TenantEvent;
translateCommon: (key: string, options?: Record<string, unknown>) => string;
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
translateCommon: (key: string, fallback?: 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)),
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, 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">
<FrostedSurface className="p-5 transition hover:-translate-y-0.5 hover:shadow-lg">
{limitWarnings.length > 0 && (
<div className="mb-3 space-y-1">
<div className="mb-4 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
@@ -143,61 +236,63 @@ function EventCard({
</div>
)}
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-3 py-1 font-medium text-pink-700">
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100/90 px-3 py-1 font-medium text-pink-700">
<CalendarDays className="h-3.5 w-3.5" />
{formatDate(event.event_date)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-3 py-1 font-medium text-sky-700">
Photos: {photoCount}
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100/90 px-3 py-1 font-medium text-sky-700">
{translate('events.list.badges.photos', 'Fotos')}: {photoCount}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1 font-medium text-amber-700">
Likes: {likeCount}
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100/90 px-3 py-1 font-medium text-amber-700">
{translate('events.list.badges.likes', 'Likes')}: {likeCount}
</span>
</div>
</div>
<Badge
className={
isPublished
? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30'
: 'bg-slate-200 text-slate-700'
}
>
{isPublished ? 'Veroeffentlicht' : 'Entwurf'}
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
{isPublished
? translateCommon('events.status.published', 'Veröffentlicht')
: translateCommon('events.status.draft', 'Entwurf')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
Details <ArrowRight className="ml-1 h-3.5 w-3.5" />
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
<Button asChild variant="outline" className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>Bearbeiten</Link>
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>{translateCommon('actions.edit', 'Bearbeiten')}</Link>
</Button>
<Button asChild variant="outline" className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>Fotos moderieren</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>Mitglieder</Link>
</Button>
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>Toolkit</Link>
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>
{translate('events.list.actions.members', 'Mitglieder')}
</Link>
</Button>
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>
{translate('events.list.actions.tasks', 'Tasks')}
</Link>
</Button>
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
<Share2 className="h-3.5 w-3.5" /> {translate('events.list.actions.invites', 'QR-Einladungen')}
</Link>
</Button>
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>{translate('events.list.actions.toolkit', 'Toolkit')}</Link>
</Button>
</div>
</div>
</FrostedSurface>
);
}
@@ -205,9 +300,9 @@ function LoadingState() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
<FrostedSurface
key={index}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
className="h-24 animate-pulse bg-gradient-to-r from-white/40 via-white/70 to-white/40"
/>
))}
</div>
@@ -216,11 +311,11 @@ function LoadingState() {
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-pink-200 bg-white/70 p-10 text-center">
<FrostedSurface className="flex flex-col items-center justify-center gap-4 border-dashed border-pink-200/70 p-10 text-center">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Plus className="h-5 w-5" />
</div>
<div className="space-y-1">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">Noch kein Event angelegt</h3>
<p className="text-sm text-slate-600">
Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.
@@ -228,11 +323,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
</div>
<Button
onClick={onCreate}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
className="rounded-full bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-6 text-white shadow-lg shadow-pink-500/20"
>
<Plus className="mr-1 h-4 w-4" /> Event erstellen
</Button>
</div>
</FrostedSurface>
);
}

View File

@@ -9,13 +9,20 @@ import AppLogoIcon from '@/components/app-logo-icon';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, encodeReturnTo, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
import {
buildAdminOAuthStartPath,
buildMarketingLoginUrl,
encodeReturnTo,
isPermittedReturnTarget,
resolveReturnTarget,
storeLastDestination,
} from '../lib/returnTo';
interface LocationState {
from?: Location;
}
export default function LoginPage(): JSX.Element {
export default function LoginPage() {
const { status, login } = useAuth();
const { t } = useTranslation('auth');
const location = useLocation();
@@ -25,9 +32,22 @@ export default function LoginPage(): JSX.Element {
const oauthError = searchParams.get('error');
const oauthErrorDescription = searchParams.get('error_description');
const rawReturnTo = searchParams.get('return_to');
const state = location.state as LocationState | null;
const fallbackTarget = React.useMemo(() => {
if (state?.from) {
const from = state.from;
const search = from.search ?? '';
const hash = from.hash ?? '';
const composed = `${from.pathname}${search}${hash}`;
if (isPermittedReturnTarget(composed)) {
return composed;
}
}
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}, [state]);
const { finalTarget, encodedFinal } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
[rawReturnTo]
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
[fallbackTarget, rawReturnTo]
);
const resolvedErrorMessage = React.useMemo(() => {
@@ -60,7 +80,6 @@ export default function LoginPage(): JSX.Element {
return finalTarget;
}
const state = location.state as LocationState | null;
if (state?.from) {
const from = state.from;
const search = from.search ?? '';
@@ -71,7 +90,7 @@ export default function LoginPage(): JSX.Element {
}
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}, [finalTarget, location.state]);
}, [finalTarget, state]);
const shouldOpenAccountLogin = oauthError === 'login_required';
const isLoading = status === 'loading';
@@ -199,7 +218,7 @@ export default function LoginPage(): JSX.Element {
);
}
function GoogleIcon({ className }: { className?: string }): JSX.Element {
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path

View File

@@ -1,21 +1,44 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Location, useLocation, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
import {
buildAdminOAuthStartPath,
buildMarketingLoginUrl,
isPermittedReturnTarget,
resolveReturnTarget,
storeLastDestination,
} from '../lib/returnTo';
export default function LoginStartPage(): JSX.Element {
interface LocationState {
from?: Location;
}
export default function LoginStartPage() {
const { status, login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const locationState = location.state as LocationState | null;
const fallbackTarget = React.useMemo(() => {
const from = locationState?.from;
if (from) {
const search = from.search ?? '';
const hash = from.hash ?? '';
const combined = `${from.pathname}${search}${hash}`;
if (isPermittedReturnTarget(combined)) {
return combined;
}
}
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
}, [locationState]);
const rawReturnTo = searchParams.get('return_to');
const { finalTarget, encodedFinal } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
[rawReturnTo]
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
[fallbackTarget, rawReturnTo]
);
const [hasStarted, setHasStarted] = React.useState(false);

View File

@@ -41,7 +41,7 @@ function extractFieldErrors(error: unknown): FieldErrors {
const DEFAULT_LOCALES = ['de', 'en'];
const AUTO_LOCALE_OPTION = '__auto__';
export default function ProfilePage(): JSX.Element {
export default function ProfilePage() {
const { t } = useTranslation(['settings', 'common']);
const { refreshProfile } = useAuth();

View File

@@ -4,11 +4,12 @@ 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 { 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 { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
import { useAuth } from '../auth/context';
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
@@ -33,6 +34,50 @@ export default function SettingsPage() {
const [notificationError, setNotificationError] = React.useState<string | null>(null);
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | null>(null);
const heroDescription = t('settings.hero.description', { defaultValue: 'Gestalte das Erlebnis für dein Admin-Team Darstellung, Benachrichtigungen und Session-Sicherheit.' });
const heroSupporting = [
t('settings.hero.summary.appearance', { defaultValue: 'Synchronisiere den Look & Feel mit dem Gästeportal oder schalte den Dark Mode frei.' }),
t('settings.hero.summary.notifications', { defaultValue: 'Stimme Benachrichtigungen auf Aufgaben, Pakete und Live-Events ab.' })
];
const accountName = user?.name ?? user?.email ?? 'Tenant Admin';
const heroPrimaryAction = (
<Button
size="sm"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => navigate(ADMIN_PROFILE_PATH)}
>
{t('settings.hero.actions.profile', 'Profil bearbeiten')}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
variant="outline"
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
>
{t('settings.hero.actions.events', 'Zur Event-Übersicht')}
</Button>
);
const heroAside = (
<FrostedSurface className="space-y-3 border-white/25 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/80">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">{t('settings.hero.accountLabel', { defaultValue: 'Angemeldeter Account' })}</p>
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{accountName}</p>
{user?.tenant_id ? (
<p className="text-xs text-slate-600 dark:text-slate-400">Tenant #{user.tenant_id}</p>
) : null}
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{t('settings.hero.support', { defaultValue: 'Passe Einstellungen für dich und dein Team an Änderungen wirken sofort im Admin.' })}</p>
</FrostedSurface>
);
const translateNotification = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
function handleLogout() {
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
let marketingUrl = buildMarketingLoginUrl(targetPath);
@@ -55,23 +100,22 @@ export default function SettingsPage() {
})();
}, [t]);
const actions = (
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
Zurück zur Übersicht
</Button>
);
return (
<AdminLayout
title="Einstellungen"
subtitle="Passe das Erscheinungsbild deines Dashboards an und verwalte deine Session."
actions={actions}
>
<Card className="max-w-2xl border-0 bg-white/85 shadow-xl shadow-amber-100/60">
<TenantHeroCard
badge={t('settings.hero.badge', { defaultValue: 'Administration' })}
title={t('settings.title', { defaultValue: 'Einstellungen' })}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<FrostedCard className="mt-6 max-w-2xl border border-white/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
@@ -114,9 +158,9 @@ export default function SettingsPage() {
</div>
</section>
</CardContent>
</Card>
</FrostedCard>
<Card className="mt-8 max-w-3xl border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<FrostedCard className="max-w-3xl border border-white/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<AlertTriangle className="h-5 w-5 text-pink-500" />
@@ -136,9 +180,9 @@ export default function SettingsPage() {
{loadingNotifications ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<div
<FrostedSurface
key={index}
className="h-12 animate-pulse rounded-xl bg-gradient-to-r from-white/30 via-white/60 to-white/30"
className="h-12 animate-pulse border border-white/20 bg-gradient-to-r from-white/30 via-white/60 to-white/30 shadow-inner dark:border-slate-800/60 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800"
/>
))}
</div>
@@ -173,11 +217,11 @@ export default function SettingsPage() {
}
}}
saving={savingNotifications}
translate={t}
translate={translateNotification}
/>
) : null}
</CardContent>
</Card>
</FrostedCard>
</AdminLayout>
);
}
@@ -199,7 +243,7 @@ function NotificationPreferencesForm({
onReset: () => void;
onSave: () => Promise<void>;
saving: boolean;
translate: (key: string, options?: Record<string, unknown>) => string;
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
}) {
const items = React.useMemo(() => buildPreferenceMeta(translate), [translate]);
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
@@ -226,37 +270,37 @@ function NotificationPreferencesForm({
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">
<FrostedSurface key={item.key} className="flex items-start justify-between gap-4 border border-pink-100/60 p-4 text-slate-900 shadow-sm shadow-rose-200/20 dark:border-pink-500/20 dark:bg-slate-950/80">
<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>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{item.label}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={(value) => onChange({ ...preferences, [item.key]: Boolean(value) })}
/>
</div>
</FrostedSurface>
);
})}
</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">
<Button onClick={onSave} disabled={saving} className="bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-md shadow-rose-400/30">
{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">
<span className="text-xs text-slate-500 dark:text-slate-400">
{translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
</span>
</div>
{creditText && <p className="text-xs text-slate-500">{creditText}</p>}
{creditText ? <p className="text-xs text-slate-500 dark:text-slate-400">{creditText}</p> : null}
</div>
);
}
function buildPreferenceMeta(
translate: (key: string, options?: Record<string, unknown>) => string
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string
): Array<{ key: keyof NotificationPreferences; label: string; description: string }> {
const map = [
{

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import type { Locale } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
import { Layers, Library, Loader2, Plus } from 'lucide-react';
@@ -40,7 +41,7 @@ export type TaskCollectionsSectionProps = {
onNavigateToTasks?: () => void;
};
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps): JSX.Element {
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps) {
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
@@ -297,7 +298,7 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
);
}
export default function TaskCollectionsPage(): JSX.Element {
export default function TaskCollectionsPage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
return (

View File

@@ -47,7 +47,7 @@ export type TasksSectionProps = {
onNavigateToCollections?: () => void;
};
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps): JSX.Element {
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps) {
const navigate = useNavigate();
const { t } = useTranslation('common');
@@ -300,7 +300,9 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
<Label htmlFor="task-priority">Priorität</Label>
<Select
value={form.priority ?? 'medium'}
onValueChange={(value: TaskPayload['priority']) => setForm((prev) => ({ ...prev, priority: value }))}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))
}
>
<SelectTrigger id="task-priority">
<SelectValue placeholder="Priorität wählen" />
@@ -309,6 +311,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="medium">Mittel</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
<SelectItem value="urgent">Dringend</SelectItem>
</SelectContent>
</Select>
</div>
@@ -347,7 +350,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
);
}
export default function TasksPage(): JSX.Element {
export default function TasksPage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tc } = useTranslation('common');
@@ -408,6 +411,7 @@ function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priori
low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
high: { label: 'Hoch', className: 'bg-rose-50 text-rose-600' },
urgent: { label: 'Dringend', className: 'bg-red-50 text-red-600' },
};
const { label, className } = mapping[priority];
return <Badge className={`border-none ${className}`}>{label}</Badge>;

View File

@@ -189,7 +189,7 @@ export function DesignerCanvas({
return;
}
canvas.selection = !readOnly;
canvas.forEachObject((object) => {
canvas.forEachObject((object: fabric.Object) => {
object.set({
selectable: !readOnly,
hoverCursor: readOnly ? 'default' : 'move',
@@ -216,11 +216,12 @@ export function DesignerCanvas({
onSelect(active.elementId);
};
const handleSelectionCleared = (event?: fabric.IEvent<MouseEvent>) => {
const handleSelectionCleared = (event?: unknown) => {
const pointerEvent = event as { e?: MouseEvent } | undefined;
if (readOnly) {
return;
}
const triggeredByPointer = Boolean(event?.e);
const triggeredByPointer = Boolean(pointerEvent?.e);
if (!triggeredByPointer && requestedSelectionRef.current) {
return;
}
@@ -245,14 +246,16 @@ export function DesignerCanvas({
};
// Manual collision check: Calculate overlap and push vertically
const otherObjects = canvas.getObjects().filter(obj => obj !== target && (obj as FabricObjectWithId).elementId);
otherObjects.forEach(other => {
const otherObjects = canvas
.getObjects()
.filter((obj): obj is FabricObjectWithId => obj !== target && Boolean((obj as FabricObjectWithId).elementId));
otherObjects.forEach((other) => {
const otherBounds = other.getBoundingRect();
const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left));
const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top));
if (overlapX > 0 && overlapY > 0) {
// Push down by 120px if overlap (massive spacing für größeren QR-Code)
nextPatch.y = Math.max(nextPatch.y, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
nextPatch.y = Math.max(nextPatch.y ?? 0, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
}
});
@@ -388,7 +391,7 @@ export function DesignerCanvas({
const match = canvas
.getObjects()
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
.find((object): object is FabricObjectWithId => (object as FabricObjectWithId).elementId === selectedId);
if (match) {
canvas.setActiveObject(match);
@@ -483,7 +486,7 @@ export async function renderFabricLayout(
qrCodeDataUrl,
logoDataUrl,
readOnly,
}, abortController.signal),
}),
);
const fabricObjects = await Promise.all(objectPromises);

View File

@@ -25,7 +25,6 @@ export async function withFabricCanvas<T>(
await renderFabricLayout(canvas, {
...options,
readOnly: true,
selectedId: null,
});
return await handler(canvas, canvasElement);
} finally {
@@ -102,7 +101,9 @@ export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
}
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<void> {
const blobUrl = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const arrayBuffer = pdfBytes.buffer.slice(pdfBytes.byteOffset, pdfBytes.byteOffset + pdfBytes.byteLength) as ArrayBuffer;
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(blob);
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
if (!printWindow) {

View File

@@ -4,8 +4,15 @@ import { type SharedData } from '@/types';
export default function AppLogo() {
const { translations } = usePage<SharedData>().props;
const areaLabel =
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
const navigationTranslations =
translations && typeof translations.dashboard === 'object' && translations.dashboard !== null
? (translations.dashboard as Record<string, unknown>).navigation
: null;
const groupLabel =
navigationTranslations && typeof navigationTranslations === 'object' && navigationTranslations !== null
? (navigationTranslations as Record<string, unknown>).group_label
: null;
const areaLabel = typeof groupLabel === 'string' ? groupLabel : 'Kundenbereich';
return (
<div className="flex items-center gap-3">

View File

@@ -19,7 +19,15 @@ export function DashboardLanguageSwitcher() {
const { locale, supportedLocales, translations } = page.props;
const locales = supportedLocales && supportedLocales.length > 0 ? supportedLocales : ['de', 'en'];
const activeLocale = locales.includes(locale as string) && typeof locale === 'string' ? locale : locales[0];
const languageCopy = (translations?.dashboard?.language_switcher as Record<string, string | undefined>) ?? {};
const dashboardTranslations =
translations && typeof translations.dashboard === 'object' && translations.dashboard !== null
? (translations.dashboard as Record<string, unknown>)
: null;
const languageSwitcherTranslations =
dashboardTranslations && typeof dashboardTranslations.language_switcher === 'object'
? (dashboardTranslations.language_switcher as Record<string, string | undefined>)
: {};
const languageCopy = languageSwitcherTranslations ?? {};
const label = languageCopy.label ?? 'Sprache';
const changeLabel = languageCopy.change ?? 'Sprache wechseln';

View File

@@ -5,8 +5,15 @@ import { Link, usePage } from '@inertiajs/react';
export function NavMain({ items = [] }: { items: NavItem[] }) {
const page = usePage<SharedData>();
const { translations } = page.props;
const groupLabel =
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
const navigationTranslations =
translations && typeof translations.dashboard === 'object' && translations.dashboard !== null
? (translations.dashboard as Record<string, unknown>).navigation
: null;
const groupLabelValue =
navigationTranslations && typeof navigationTranslations === 'object' && navigationTranslations !== null
? (navigationTranslations as Record<string, unknown>).group_label
: null;
const groupLabel = typeof groupLabelValue === 'string' ? groupLabelValue : 'Kundenbereich';
return (
<SidebarGroup className="px-2 py-0">

View File

@@ -12,8 +12,8 @@
/* Language and Environment */
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
"lib": ["DOM", "DOM.Iterable", "ESNext"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
"jsx": "react-jsx", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
@@ -110,8 +110,19 @@
"baseUrl": ".",
"paths": {
"@/*": ["./resources/js/*"]
}
},
"jsx": "react-jsx"
},
"include": ["resources/js/**/*.ts", "resources/js/**/*.d.ts", "resources/js/**/*.tsx"]
"include": [
"resources/js/admin/**/*.ts",
"resources/js/admin/**/*.tsx",
"resources/js/components/**/*.ts",
"resources/js/components/**/*.tsx",
"resources/js/actions/**/*.ts",
"resources/js/actions/**/*.tsx",
"resources/js/routes/**/*.ts",
"resources/js/routes/**/*.tsx",
"resources/js/types/**/*.ts",
"resources/js/lib/**/*.ts",
"resources/js/lib/**/*.tsx"
]
}