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:
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { ApiError, emitApiErrorEvent } from './lib/apiError';
|
|||||||
import type { EventLimitSummary } from './lib/limitWarnings';
|
import type { EventLimitSummary } from './lib/limitWarnings';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
type JsonValue = Record<string, unknown>;
|
type JsonValue = Record<string, any>;
|
||||||
|
|
||||||
export type TenantAccountProfile = {
|
export type TenantAccountProfile = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -98,6 +98,7 @@ export type TenantPhoto = {
|
|||||||
likes_count: number;
|
likes_count: number;
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
uploader_name: string | null;
|
uploader_name: string | null;
|
||||||
|
caption?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EventStats = {
|
export type EventStats = {
|
||||||
@@ -107,6 +108,10 @@ export type EventStats = {
|
|||||||
recent_uploads: number;
|
recent_uploads: number;
|
||||||
status: string;
|
status: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
uploads_total?: number;
|
||||||
|
uploads_24h?: number;
|
||||||
|
likes_total?: number;
|
||||||
|
pending_photos?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginationMeta = {
|
export type PaginationMeta = {
|
||||||
@@ -156,7 +161,9 @@ export async function trackOnboarding(step: string, meta?: Record<string, unknow
|
|||||||
body: JSON.stringify({ step, meta }),
|
body: JSON.stringify({ step, meta }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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');
|
const response = await authorizedFetch('/api/v1/tenant/onboarding');
|
||||||
return (await response.json()) as TenantOnboardingStatus;
|
return (await response.json()) as TenantOnboardingStatus;
|
||||||
} catch (error) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +241,7 @@ export type TenantTask = {
|
|||||||
difficulty: 'easy' | 'medium' | 'hard' | null;
|
difficulty: 'easy' | 'medium' | 'hard' | null;
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
|
tenant_id: number | null;
|
||||||
collection_id: number | null;
|
collection_id: number | null;
|
||||||
source_task_id: number | null;
|
source_task_id: number | null;
|
||||||
source_collection_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 body = await safeJson(response);
|
||||||
const status = response.status;
|
const status = response.status;
|
||||||
const errorPayload = body && typeof body === 'object' ? (body as Record<string, unknown>).error : null;
|
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')
|
const errorRecord = errorPayload && typeof errorPayload === 'object'
|
||||||
? errorPayload.message
|
? (errorPayload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const errorMessage = errorRecord && typeof errorRecord.message === 'string'
|
||||||
|
? errorRecord.message
|
||||||
: message;
|
: message;
|
||||||
const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string'
|
const errorCode = errorRecord && typeof errorRecord.code === 'string'
|
||||||
? errorPayload.code
|
? errorRecord.code
|
||||||
: undefined;
|
: undefined;
|
||||||
let errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
|
let errorMeta = errorRecord && typeof errorRecord.meta === 'object'
|
||||||
? errorPayload.meta as Record<string, unknown>
|
? (errorRecord.meta ?? null) as Record<string, unknown>
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!errorMeta && body && typeof body === 'object' && 'errors' in body && typeof body.errors === 'object') {
|
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),
|
likes_count: Number(photo.likes_count ?? 0),
|
||||||
uploaded_at: photo.uploaded_at,
|
uploaded_at: photo.uploaded_at,
|
||||||
uploader_name: photo.uploader_name ?? null,
|
uploader_name: photo.uploader_name ?? null,
|
||||||
|
caption: photo.caption ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,7 +668,6 @@ function normalizeTask(task: JsonValue): TenantTask {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(task.id ?? 0),
|
id: Number(task.id ?? 0),
|
||||||
tenant_id: task.tenant_id ?? null,
|
|
||||||
slug: String(task.slug ?? `task-${task.id ?? ''}`),
|
slug: String(task.slug ?? `task-${task.id ?? ''}`),
|
||||||
title: pickTranslatedText(titleTranslations, 'Ohne Titel'),
|
title: pickTranslatedText(titleTranslations, 'Ohne Titel'),
|
||||||
title_translations: titleTranslations,
|
title_translations: titleTranslations,
|
||||||
@@ -668,6 +681,7 @@ function normalizeTask(task: JsonValue): TenantTask {
|
|||||||
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
|
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
|
||||||
due_date: task.due_date ?? null,
|
due_date: task.due_date ?? null,
|
||||||
is_completed: Boolean(task.is_completed ?? false),
|
is_completed: Boolean(task.is_completed ?? false),
|
||||||
|
tenant_id: task.tenant_id ?? null,
|
||||||
collection_id: task.collection_id ?? null,
|
collection_id: task.collection_id ?? null,
|
||||||
source_task_id: task.source_task_id ?? null,
|
source_task_id: task.source_task_id ?? null,
|
||||||
source_collection_id: task.source_collection_id ?? null,
|
source_collection_id: task.source_collection_id ?? null,
|
||||||
@@ -680,7 +694,11 @@ function normalizeTask(task: JsonValue): TenantTask {
|
|||||||
|
|
||||||
function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
|
function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
|
||||||
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
|
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;
|
const eventTypeRaw = raw.event_type ?? raw.eventType ?? null;
|
||||||
let eventType: TenantTaskCollection['event_type'] = null;
|
let eventType: TenantTaskCollection['event_type'] = null;
|
||||||
@@ -715,7 +733,11 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
|
|||||||
|
|
||||||
function normalizeEmotion(raw: JsonValue): TenantEmotion {
|
function normalizeEmotion(raw: JsonValue): TenantEmotion {
|
||||||
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
|
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)
|
const eventTypes = Array.isArray(raw.event_types ?? raw.eventTypes)
|
||||||
? (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),
|
recent_uploads: Number(data.recent_uploads ?? 0),
|
||||||
status: data.status ?? 'draft',
|
status: data.status ?? 'draft',
|
||||||
is_active: Boolean(data.is_active),
|
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: {
|
photos: {
|
||||||
pending: pendingPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
|
pending: pendingPhotosRaw.map((photo: JsonValue) => normalizePhoto(photo as TenantPhoto)),
|
||||||
recent: recentPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
|
recent: recentPhotosRaw.map((photo: JsonValue) => normalizePhoto(photo as TenantPhoto)),
|
||||||
},
|
},
|
||||||
invites: {
|
invites: {
|
||||||
summary: {
|
summary: {
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ import {
|
|||||||
ADMIN_BILLING_PATH,
|
ADMIN_BILLING_PATH,
|
||||||
ADMIN_ENGAGEMENT_PATH,
|
ADMIN_ENGAGEMENT_PATH,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
CalendarDays,
|
||||||
|
Sparkles,
|
||||||
|
CreditCard,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
import { registerApiErrorListener } from '../lib/apiError';
|
import { registerApiErrorListener } from '../lib/apiError';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
|
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', icon: LayoutDashboard, end: true },
|
||||||
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
|
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events', icon: CalendarDays },
|
||||||
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement' },
|
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement', icon: Sparkles },
|
||||||
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
|
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing', icon: CreditCard },
|
||||||
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' },
|
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings', icon: SettingsIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
@@ -51,43 +58,85 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-brand-gradient text-brand-slate">
|
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
|
||||||
<header className="border-b border-brand-rose-soft bg-brand-card/90 shadow-brand-primary backdrop-blur-md">
|
<div
|
||||||
<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">
|
aria-hidden
|
||||||
<div>
|
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%)]"
|
||||||
<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>
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/60 to-[#1d1130]" />
|
||||||
{subtitle && <p className="mt-1 text-sm font-sans-marketing text-brand-navy/75">{subtitle}</p>}
|
<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-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="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={to}
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? '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'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{t(labelKey)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</header>
|
||||||
<LanguageSwitcher />
|
|
||||||
{actions}
|
<main className="relative z-10 mx-auto w-full max-w-6xl flex-1 px-4 pb-28 pt-6 sm:px-6">
|
||||||
</div>
|
<div className="grid gap-6">{children}</div>
|
||||||
</div>
|
</main>
|
||||||
<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) => (
|
<TenantMobileNav items={navItems} />
|
||||||
<NavLink
|
</div>
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
end={item.end}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
'rounded-full px-4 py-2 transition-colors',
|
|
||||||
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'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t(item.labelKey)}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="mx-auto w-full max-w-6xl px-6 py-10">
|
|
||||||
<div className="grid gap-6">{children}</div>
|
|
||||||
</main>
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,3 +120,5 @@ export function DevTenantSwitcher() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default DevTenantSwitcher;
|
||||||
|
|||||||
64
resources/js/admin/components/tenant/checklist-row.tsx
Normal file
64
resources/js/admin/components/tenant/checklist-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
resources/js/admin/components/tenant/frosted-surface.tsx
Normal file
32
resources/js/admin/components/tenant/frosted-surface.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
resources/js/admin/components/tenant/hero-card.tsx
Normal file
74
resources/js/admin/components/tenant/hero-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
resources/js/admin/components/tenant/index.ts
Normal file
6
resources/js/admin/components/tenant/index.ts
Normal 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';
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH;
|
||||||
export const ADMIN_HOME_PATH = adminPath('/dashboard');
|
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_PATH = adminPath('/login');
|
||||||
export const ADMIN_LOGIN_START_PATH = adminPath('/start');
|
export const ADMIN_LOGIN_START_PATH = adminPath('/start');
|
||||||
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
|
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
|
||||||
|
|||||||
@@ -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));
|
console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
|
||||||
|
|
||||||
// @ts-expect-error Dev helper for debugging only.
|
(window as typeof window & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
|
||||||
window.fotospielDemoAuth = api;
|
(globalThis as typeof globalThis & { fotospielDemoAuth?: typeof api }).fotospielDemoAuth = api;
|
||||||
// @ts-expect-error Dev helper for debugging only.
|
|
||||||
globalThis.fotospielDemoAuth = api;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
|
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
|
||||||
|
|||||||
@@ -47,6 +47,29 @@
|
|||||||
"button": "Zum Event-Manager"
|
"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": {
|
"packages": {
|
||||||
"layout": {
|
"layout": {
|
||||||
"eyebrow": "Schritt 2",
|
"eyebrow": "Schritt 2",
|
||||||
@@ -240,4 +263,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,29 @@
|
|||||||
"button": "Go to event manager"
|
"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": {
|
"packages": {
|
||||||
"layout": {
|
"layout": {
|
||||||
"eyebrow": "Step 2",
|
"eyebrow": "Step 2",
|
||||||
@@ -240,4 +263,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 LAST_DESTINATION_KEY = 'tenant.oauth.lastDestination';
|
||||||
|
const DASHBOARD_PREFIX = '/dashboard';
|
||||||
|
|
||||||
function ensureLeadingSlash(target: string): string {
|
function ensureLeadingSlash(target: string): string {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -14,6 +15,28 @@ function ensureLeadingSlash(target: string): string {
|
|||||||
return target.startsWith('/') ? target : `/${target}`;
|
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 {
|
function base64UrlEncode(value: string): string {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const bytes = encoder.encode(value);
|
const bytes = encoder.encode(value);
|
||||||
@@ -65,16 +88,19 @@ export interface ReturnTargetResolution {
|
|||||||
|
|
||||||
export function resolveReturnTarget(raw: string | null, fallback: string): ReturnTargetResolution {
|
export function resolveReturnTarget(raw: string | null, fallback: string): ReturnTargetResolution {
|
||||||
const normalizedFallback = ensureLeadingSlash(fallback);
|
const normalizedFallback = ensureLeadingSlash(fallback);
|
||||||
|
const fallbackIsAllowed = isPermittedReturnTarget(normalizedFallback)
|
||||||
|
? normalizedFallback
|
||||||
|
: ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
const encoded = encodeReturnTo(normalizedFallback);
|
const encoded = encodeReturnTo(fallbackIsAllowed);
|
||||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedPrimary = decodeReturnTo(raw);
|
const decodedPrimary = decodeReturnTo(raw);
|
||||||
if (!decodedPrimary) {
|
if (!decodedPrimary) {
|
||||||
const encoded = encodeReturnTo(normalizedFallback);
|
const encoded = encodeReturnTo(fallbackIsAllowed);
|
||||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPrimary = decodedPrimary.trim();
|
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 url = new URL(normalizedPrimary, window.location.origin);
|
||||||
const innerRaw = url.searchParams.get('return_to');
|
const innerRaw = url.searchParams.get('return_to');
|
||||||
if (!innerRaw) {
|
if (!innerRaw) {
|
||||||
const encoded = encodeReturnTo(normalizedFallback);
|
const encoded = encodeReturnTo(fallbackIsAllowed);
|
||||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveReturnTarget(innerRaw, normalizedFallback);
|
return resolveReturnTarget(innerRaw, fallbackIsAllowed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Auth] Failed to parse return_to chain', error);
|
console.warn('[Auth] Failed to parse return_to chain', error);
|
||||||
const encoded = encodeReturnTo(normalizedFallback);
|
const encoded = encodeReturnTo(fallbackIsAllowed);
|
||||||
return { finalTarget: normalizedFallback, encodedFinal: encoded };
|
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTarget = ensureLeadingSlash(normalizedPrimary);
|
const finalTarget = ensureLeadingSlash(normalizedPrimary);
|
||||||
|
if (!isPermittedReturnTarget(finalTarget)) {
|
||||||
|
const encoded = encodeReturnTo(fallbackIsAllowed);
|
||||||
|
return { finalTarget: fallbackIsAllowed, encodedFinal: encoded };
|
||||||
|
}
|
||||||
|
|
||||||
const encodedFinal = encodeReturnTo(finalTarget);
|
const encodedFinal = encodeReturnTo(finalTarget);
|
||||||
|
|
||||||
return { finalTarget, encodedFinal };
|
return { finalTarget, encodedFinal };
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
|
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
|
||||||
|
|
||||||
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
|
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
|
||||||
@@ -21,12 +23,13 @@ describe('PaddleCheckout', () => {
|
|||||||
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
|
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
|
||||||
const onSuccess = vi.fn();
|
const onSuccess = vi.fn();
|
||||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
const tMock = ((key: string) => key) as unknown as TFunction;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<PaddleCheckout
|
<PaddleCheckout
|
||||||
packageId={99}
|
packageId={99}
|
||||||
onSuccess={onSuccess}
|
onSuccess={onSuccess}
|
||||||
t={(key: string) => key}
|
t={tMock}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -45,12 +48,13 @@ describe('PaddleCheckout', () => {
|
|||||||
|
|
||||||
it('shows an error message on failure', async () => {
|
it('shows an error message on failure', async () => {
|
||||||
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
|
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
|
||||||
|
const tMock = ((key: string) => key) as unknown as TFunction;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<PaddleCheckout
|
<PaddleCheckout
|
||||||
packageId={99}
|
packageId={99}
|
||||||
onSuccess={vi.fn()}
|
onSuccess={vi.fn()}
|
||||||
t={(key: string) => key}
|
t={tMock}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -59,7 +63,7 @@ describe('PaddleCheckout', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('summary.paddle.genericError')).toBeInTheDocument();
|
expect(screen.getByRole('alert')).toHaveTextContent('boom');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FrostedSurface } from '../../components/tenant';
|
||||||
|
|
||||||
export interface OnboardingAction {
|
export interface OnboardingAction {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -28,30 +30,30 @@ export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps
|
|||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-4 md:grid-cols-2', className)}>
|
<div className={cn('grid gap-4 md:grid-cols-2', className)}>
|
||||||
{actions.map(({ id, label, description, href, onClick, icon: Icon, variant = 'primary', disabled, buttonLabel }) => (
|
{actions.map(({ id, label, description, href, onClick, icon: Icon, variant = 'primary', disabled, buttonLabel }) => (
|
||||||
<div
|
<FrostedSurface
|
||||||
key={id}
|
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">
|
<div className="flex items-center gap-3">
|
||||||
{Icon && (
|
{Icon ? (
|
||||||
<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">
|
||||||
<Icon className="size-5" />
|
<Icon className="size-5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
<span className="text-base font-semibold text-brand-slate">{label}</span>
|
<span className="text-base font-semibold text-slate-900 dark:text-slate-100">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description ? (
|
||||||
<p className="text-sm text-brand-navy/80">{description}</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||||
)}
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-full transition-all',
|
'w-full rounded-full transition-colors',
|
||||||
variant === 'secondary'
|
variant === 'secondary'
|
||||||
? 'bg-brand-gold text-brand-slate shadow-md shadow-amber-200/40 hover:bg-[var(--brand-gold-soft)]'
|
? 'bg-slate-900/80 text-white shadow-md shadow-slate-900/30 hover:bg-slate-900 dark:bg-slate-800'
|
||||||
: 'bg-brand-rose text-white shadow-md shadow-rose-400/30 hover:bg-[var(--brand-rose-strong)]'
|
: '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}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -60,7 +62,7 @@ export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps
|
|||||||
{href ? <a href={href}>{buttonLabel ?? label}</a> : buttonLabel ?? label}
|
{href ? <a href={href}>{buttonLabel ?? label}</a> : buttonLabel ?? label}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
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 { LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { FrostedSurface } from '../../components/tenant';
|
||||||
|
|
||||||
export interface HighlightItem {
|
export interface HighlightItem {
|
||||||
id: string;
|
id: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
@@ -24,27 +26,27 @@ export function OnboardingHighlightsGrid({ items, className }: OnboardingHighlig
|
|||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-4 md:grid-cols-3', className)}>
|
<div className={cn('grid gap-4 md:grid-cols-3', className)}>
|
||||||
{items.map(({ id, icon: Icon, title, description, badge }) => (
|
{items.map(({ id, icon: Icon, title, description, badge }) => (
|
||||||
<Card
|
<FrostedSurface
|
||||||
key={id}
|
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">
|
<CardHeader className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<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" />
|
<Icon className="size-6" />
|
||||||
</span>
|
</span>
|
||||||
{badge && (
|
{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}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-slate-600">{description}</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</FrostedSurface>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LanguageSwitcher } from '../../components/LanguageSwitcher';
|
import { LanguageSwitcher } from '../../components/LanguageSwitcher';
|
||||||
|
import { FrostedSurface } from '../../components/tenant';
|
||||||
|
|
||||||
export interface TenantWelcomeLayoutProps {
|
export interface TenantWelcomeLayoutProps {
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
@@ -27,24 +28,28 @@ export function TenantWelcomeLayout({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-brand-gradient text-brand-slate transition-colors duration-500 ease-out">
|
<div className="relative min-h-svh w-full overflow-hidden bg-slate-950 text-white">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-5xl px-6 py-12 md:py-16 lg:px-10">
|
<div
|
||||||
<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">
|
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">
|
<header className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl space-y-4">
|
||||||
{eyebrow && (
|
{eyebrow ? (
|
||||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">{eyebrow}</p>
|
<p className="text-xs uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200">{eyebrow}</p>
|
||||||
)}
|
) : null}
|
||||||
{title && (
|
{title ? (
|
||||||
<h1 className="mt-2 font-display text-4xl font-semibold tracking-tight text-brand-slate md:text-5xl">
|
<h1 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 md:text-5xl">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
) : null}
|
||||||
{subtitle && (
|
{subtitle ? (
|
||||||
<p className="mt-4 text-base font-sans-marketing text-brand-navy/80 md:text-lg">
|
<p className="text-base text-slate-600 dark:text-slate-300 md:text-lg">{subtitle}</p>
|
||||||
{subtitle}
|
) : null}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
@@ -56,12 +61,12 @@ export function TenantWelcomeLayout({
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{footer && (
|
{footer ? (
|
||||||
<footer className="flex flex-col items-center gap-4 text-sm text-brand-navy/70 md:flex-row md:justify-between">
|
<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}
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</FrostedSurface>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { FrostedSurface } from '../../components/tenant';
|
||||||
|
|
||||||
interface ActionProps {
|
interface ActionProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -29,28 +30,32 @@ export function WelcomeHero({
|
|||||||
className,
|
className,
|
||||||
}: WelcomeHeroProps) {
|
}: WelcomeHeroProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<FrostedSurface
|
||||||
className={cn(
|
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
|
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 && (
|
{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}
|
{eyebrow}
|
||||||
</p>
|
</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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{scriptTitle && (
|
{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}
|
{scriptTitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{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}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -62,10 +67,10 @@ export function WelcomeHero({
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
variant={variant === 'outline' ? 'outline' : 'default'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-[220px] rounded-full px-6',
|
'min-w-[220px] rounded-full px-6 transition-colors',
|
||||||
variant === 'outline'
|
variant === 'outline'
|
||||||
? 'border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40'
|
? '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-brand-rose text-white shadow-md shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]'
|
: '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}
|
onClick={onClick}
|
||||||
{...(href ? { asChild: true } : {})}
|
{...(href ? { asChild: true } : {})}
|
||||||
@@ -86,7 +91,7 @@ export function WelcomeHero({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React from 'react';
|
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 { Progress } from '@/components/ui/progress';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { FrostedCard } from '../../components/tenant';
|
||||||
|
|
||||||
export interface WelcomeStepCardProps {
|
export interface WelcomeStepCardProps {
|
||||||
step: number;
|
step: number;
|
||||||
totalSteps: number;
|
totalSteps: number;
|
||||||
@@ -27,16 +29,16 @@ export function WelcomeStepCard({
|
|||||||
const percent = totalSteps <= 1 ? 100 : Math.round((progress / totalSteps) * 100);
|
const percent = totalSteps <= 1 ? 100 : Math.round((progress / totalSteps) * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<FrostedCard
|
||||||
className={cn(
|
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
|
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">
|
<CardHeader className="space-y-4 pt-8">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<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}
|
Step {progress} / {totalSteps}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-28">
|
<div className="w-28">
|
||||||
@@ -45,20 +47,20 @@ export function WelcomeStepCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{Icon && (
|
{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" />
|
<Icon className="size-5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<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 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 pb-10">{children}</CardContent>
|
<CardContent className="space-y-6 pb-10 text-slate-700 dark:text-slate-300">{children}</CardContent>
|
||||||
</Card>
|
</FrostedCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "..";
|
} from "..";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants";
|
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants";
|
||||||
|
import { FrostedSurface } from "../../components/tenant";
|
||||||
|
|
||||||
export default function WelcomeEventSetupPage() {
|
export default function WelcomeEventSetupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -55,25 +56,25 @@ export default function WelcomeEventSetupPage() {
|
|||||||
icon: ArrowRight,
|
icon: ArrowRight,
|
||||||
},
|
},
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div
|
<FrostedSurface
|
||||||
key={item.id}
|
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" />
|
<item.icon className="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-lg font-semibold text-brand-slate">{item.title}</h3>
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
|
||||||
<p className="text-sm text-brand-navy/80">{item.copy}</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400">{item.copy}</p>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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-brand-rose">{t("eventSetup.cta.heading")}</h4>
|
<h4 className="text-lg font-semibold text-rose-400 dark:text-rose-200">{t("eventSetup.cta.heading")}</h4>
|
||||||
<p className="text-sm text-brand-navy/80">{t("eventSetup.cta.description")}</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400">{t("eventSetup.cta.description")}</p>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
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={() => {
|
onClick={() => {
|
||||||
markStep({ lastStep: "event-create-intent" });
|
markStep({ lastStep: "event-create-intent" });
|
||||||
navigate(ADMIN_EVENT_CREATE_PATH);
|
navigate(ADMIN_EVENT_CREATE_PATH);
|
||||||
@@ -82,7 +83,7 @@ export default function WelcomeEventSetupPage() {
|
|||||||
{t("eventSetup.cta.button")}
|
{t("eventSetup.cta.button")}
|
||||||
<ArrowRight className="ml-2 size-4" />
|
<ArrowRight className="ml-2 size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
</WelcomeStepCard>
|
</WelcomeStepCard>
|
||||||
|
|
||||||
<OnboardingCTAList
|
<OnboardingCTAList
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
import {
|
||||||
TenantWelcomeLayout,
|
TenantWelcomeLayout,
|
||||||
@@ -11,16 +11,52 @@ import {
|
|||||||
useOnboardingProgress,
|
useOnboardingProgress,
|
||||||
} from "..";
|
} from "..";
|
||||||
import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants";
|
import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants";
|
||||||
|
import { ChecklistRow, FrostedSurface } from "../../components/tenant";
|
||||||
|
|
||||||
export default function WelcomeLandingPage() {
|
export default function WelcomeLandingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { markStep } = useOnboardingProgress();
|
const { markStep, progress } = useOnboardingProgress();
|
||||||
const { t } = useTranslation("onboarding");
|
const { t } = useTranslation("onboarding");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
markStep({ welcomeSeen: true, lastStep: "landing" });
|
markStep({ welcomeSeen: true, lastStep: "landing" });
|
||||||
}, [markStep]);
|
}, [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 (
|
return (
|
||||||
<TenantWelcomeLayout
|
<TenantWelcomeLayout
|
||||||
eyebrow={t("layout.eyebrow")}
|
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
|
<OnboardingHighlightsGrid
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -17,20 +18,22 @@ import {
|
|||||||
OnboardingCTAList,
|
OnboardingCTAList,
|
||||||
useOnboardingProgress,
|
useOnboardingProgress,
|
||||||
} from "..";
|
} from "..";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
|
||||||
import { useTenantPackages } from "../hooks/useTenantPackages";
|
import { useTenantPackages } from "../hooks/useTenantPackages";
|
||||||
import {
|
import {
|
||||||
assignFreeTenantPackage,
|
assignFreeTenantPackage,
|
||||||
createTenantPaddleCheckout,
|
createTenantPaddleCheckout,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PaddleCheckoutProps = {
|
type PaddleCheckoutProps = {
|
||||||
packageId: number;
|
packageId: number;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
t: ReturnType<typeof useTranslation>["t"];
|
t: ReturnType<typeof useTranslation>["t"];
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function useLocaleFormats(locale: string) {
|
function useLocaleFormats(locale: string) {
|
||||||
@@ -69,7 +72,7 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
|
|||||||
.join(" ");
|
.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 [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -89,14 +92,32 @@ function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
|
|||||||
}, [packageId, onSuccess, t]);
|
}, [packageId, onSuccess, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
|
<FrostedSurface
|
||||||
<p className="text-sm font-medium text-brand-slate">{t('summary.paddle.heading')}</p>
|
className={cn(
|
||||||
{error && (
|
"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",
|
||||||
<Alert variant="destructive">
|
className
|
||||||
<AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<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
|
<Button
|
||||||
size="lg"
|
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"
|
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>
|
</Button>
|
||||||
<p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
|
<p className="text-xs text-slate-600 dark:text-slate-400">{t('summary.paddle.hint')}</p>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,101 +237,177 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
icon={Receipt}
|
icon={Receipt}
|
||||||
>
|
>
|
||||||
{packagesState.status === "loading" && (
|
{packagesState.status === "loading" && (
|
||||||
<div className="flex items-center gap-3 rounded-2xl bg-slate-50 p-6 text-sm text-brand-navy/80">
|
<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">
|
||||||
<CreditCard className="size-5 text-brand-rose" />
|
<Loader2 className="size-5 animate-spin text-rose-400" />
|
||||||
{t("summary.state.loading")}
|
{t("summary.state.loading")}
|
||||||
</div>
|
</FrostedSurface>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{packagesState.status === "error" && (
|
{packagesState.status === "error" && (
|
||||||
<Alert variant="destructive">
|
<FrostedSurface
|
||||||
<AlertTriangle className="size-4" />
|
role="alert"
|
||||||
<AlertTitle>{t("summary.state.errorTitle")}</AlertTitle>
|
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"
|
||||||
<AlertDescription>
|
>
|
||||||
{packagesState.message ?? t("summary.state.errorDescription")}
|
<AlertTriangle className="size-4 shrink-0 text-current" />
|
||||||
</AlertDescription>
|
<div className="space-y-1">
|
||||||
</Alert>
|
<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")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FrostedSurface>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{packagesState.status === "success" && !packageDetails && (
|
{packagesState.status === "success" && !packageDetails && (
|
||||||
<Alert>
|
<FrostedSurface
|
||||||
<AlertTitle>{t("summary.state.missingTitle")}</AlertTitle>
|
role="alert"
|
||||||
<AlertDescription>{t("summary.state.missingDescription")}</AlertDescription>
|
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"
|
||||||
</Alert>
|
>
|
||||||
|
<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 && (
|
{packagesState.status === "success" && packageDetails && (
|
||||||
<div className="grid gap-4">
|
<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">
|
<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 className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div
|
||||||
<div>
|
aria-hidden
|
||||||
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">
|
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%)]"
|
||||||
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
|
/>
|
||||||
</p>
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/65 via-slate-900/10 to-transparent mix-blend-overlay" />
|
||||||
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
|
<div className="relative z-10 flex flex-col gap-6">
|
||||||
</div>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{priceText && (
|
<div className="space-y-3">
|
||||||
<Badge className="rounded-full bg-rose-100 px-4 py-2 text-base font-semibold text-rose-600">
|
<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">
|
||||||
{priceText}
|
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
<h3 className="font-display text-2xl font-semibold tracking-tight text-white md:text-3xl">
|
||||||
</div>
|
{packageDetails.name}
|
||||||
<dl className="mt-6 grid gap-3 text-sm text-brand-navy/80 md:grid-cols-2">
|
</h3>
|
||||||
<div>
|
</div>
|
||||||
<dt className="font-semibold text-brand-slate">{t("summary.details.section.photosTitle")}</dt>
|
<div className="flex flex-col items-start gap-2 text-left sm:items-end sm:text-right">
|
||||||
<dd>
|
{priceText ? (
|
||||||
{packageDetails.max_photos
|
<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">
|
||||||
? t("summary.details.section.photosValue", {
|
{priceText}
|
||||||
count: packageDetails.max_photos,
|
</Badge>
|
||||||
days: packageDetails.gallery_days ?? t("summary.details.infinity"),
|
) : null}
|
||||||
})
|
<span className="text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
|
||||||
: t("summary.details.section.photosUnlimited")}
|
{activePackage
|
||||||
</dd>
|
? t("summary.details.section.statusActive")
|
||||||
</div>
|
: t("summary.details.section.statusInactive")}
|
||||||
<div>
|
|
||||||
<dt className="font-semibold text-brand-slate">{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>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</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", {
|
||||||
|
count: packageDetails.max_photos,
|
||||||
|
days: packageDetails.gallery_days ?? t("summary.details.infinity"),
|
||||||
|
})
|
||||||
|
: t("summary.details.section.photosUnlimited")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<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 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 && (
|
{!activePackage && (
|
||||||
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
|
<FrostedSurface
|
||||||
<ShieldCheck className="size-4" />
|
role="status"
|
||||||
<AlertTitle>{t("summary.status.pendingTitle")}</AlertTitle>
|
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"
|
||||||
<AlertDescription>{t("summary.status.pendingDescription")}</AlertDescription>
|
>
|
||||||
</Alert>
|
<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 && (
|
{packageDetails.price === 0 && (
|
||||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
|
<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 className="text-sm text-emerald-700">{t("summary.free.description")}</p>
|
<p>{t("summary.free.description")}</p>
|
||||||
{freeAssignStatus === "success" ? (
|
{freeAssignStatus === "success" ? (
|
||||||
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
|
<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">
|
||||||
<AlertTitle>{t("summary.free.successTitle")}</AlertTitle>
|
<p className="font-semibold">{t("summary.free.successTitle")}</p>
|
||||||
<AlertDescription>{t("summary.free.successDescription")}</AlertDescription>
|
<p>{t("summary.free.successDescription")}</p>
|
||||||
</Alert>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -333,7 +430,7 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={freeAssignStatus === "loading"}
|
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" ? (
|
{freeAssignStatus === "loading" ? (
|
||||||
<>
|
<>
|
||||||
@@ -345,37 +442,37 @@ export default function WelcomeOrderSummaryPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{freeAssignStatus === "error" && freeAssignError && (
|
{freeAssignStatus === "error" && freeAssignError ? (
|
||||||
<Alert variant="destructive" className="mt-3">
|
<div
|
||||||
<AlertTitle>{t("summary.free.failureTitle")}</AlertTitle>
|
role="alert"
|
||||||
<AlertDescription>{freeAssignError}</AlertDescription>
|
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"
|
||||||
</Alert>
|
>
|
||||||
)}
|
<p className="font-semibold">{t("summary.free.failureTitle")}</p>
|
||||||
</div>
|
<p>{freeAssignError}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrostedSurface>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requiresPayment && (
|
{requiresPayment && (
|
||||||
<div className="space-y-4">
|
<PaddleCheckout
|
||||||
<h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
|
packageId={packageDetails.id}
|
||||||
<PaddleCheckout
|
onSuccess={() => {
|
||||||
packageId={packageDetails.id}
|
markStep({ packageSelected: true });
|
||||||
onSuccess={() => {
|
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
||||||
markStep({ packageSelected: true });
|
}}
|
||||||
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
|
t={t}
|
||||||
}}
|
/>
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-2xl bg-brand-card p-6 shadow-inner shadow-rose-100/40">
|
<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-brand-slate">{t("summary.nextStepsTitle")}</h4>
|
<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-brand-navy/80">
|
<ol className="list-decimal space-y-2 pl-5 text-sm text-slate-600 dark:text-slate-300">
|
||||||
{nextSteps.map((step, index) => (
|
{nextSteps.map((step, index) => (
|
||||||
<li key={`${step}-${index}`}>{step}</li>
|
<li key={`${step}-${index}`}>{step}</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</WelcomeStepCard>
|
</WelcomeStepCard>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "..";
|
} from "..";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FrostedSurface } from "../../components/tenant/frosted-surface";
|
||||||
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants";
|
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants";
|
||||||
import { useTenantPackages } from "../hooks/useTenantPackages";
|
import { useTenantPackages } from "../hooks/useTenantPackages";
|
||||||
import { Package } from "../../api";
|
import { Package } from "../../api";
|
||||||
@@ -115,16 +116,16 @@ export default function WelcomePackagesPage() {
|
|||||||
const renderPackageList = () => {
|
const renderPackageList = () => {
|
||||||
if (packagesState.status === "loading") {
|
if (packagesState.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
|
<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-brand-rose" />
|
<Loader2 className="size-5 animate-spin text-rose-400" />
|
||||||
{t("packages.state.loading")}
|
{t("packages.state.loading")}
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (packagesState.status === "error") {
|
if (packagesState.status === "error") {
|
||||||
return (
|
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" />
|
<AlertCircle className="size-4" />
|
||||||
<AlertTitle>{t("packages.state.errorTitle")}</AlertTitle>
|
<AlertTitle>{t("packages.state.errorTitle")}</AlertTitle>
|
||||||
<AlertDescription>{packagesState.message ?? t("packages.state.errorDescription")}</AlertDescription>
|
<AlertDescription>{packagesState.message ?? t("packages.state.errorDescription")}</AlertDescription>
|
||||||
@@ -134,7 +135,7 @@ export default function WelcomePackagesPage() {
|
|||||||
|
|
||||||
if (packagesState.status === "success" && packagesState.catalog.length === 0) {
|
if (packagesState.status === "success" && packagesState.catalog.length === 0) {
|
||||||
return (
|
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>
|
<AlertTitle>{t("packages.state.emptyTitle")}</AlertTitle>
|
||||||
<AlertDescription>{t("packages.state.emptyDescription")}</AlertDescription>
|
<AlertDescription>{t("packages.state.emptyDescription")}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -170,59 +171,70 @@ export default function WelcomePackagesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FrostedSurface
|
||||||
key={pkg.id}
|
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 className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<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")}
|
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
|
||||||
</p>
|
</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>
|
</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>
|
</div>
|
||||||
<p className="text-sm text-brand-navy/80">
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
{pkg.max_photos
|
{pkg.max_photos
|
||||||
? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos })
|
? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos })
|
||||||
: t("packages.card.description")}
|
: t("packages.card.description")}
|
||||||
</p>
|
</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) => (
|
{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}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{featureLabels.map((feature) => (
|
{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}
|
{feature}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{isActive && (
|
{isActive ? (
|
||||||
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">
|
<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")}
|
{t("packages.card.active")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
size="lg"
|
<Button
|
||||||
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
|
size="lg"
|
||||||
onClick={() => handleSelectPackage(pkg)}
|
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)}
|
||||||
{t("packages.card.select")}
|
disabled={isActive}
|
||||||
<ArrowRight className="ml-2 size-4" />
|
>
|
||||||
</Button>
|
{isActive ? t("packages.card.active") : t("packages.card.select")}
|
||||||
{purchased && (
|
<ArrowRight className="ml-2 size-4" />
|
||||||
<p className="text-xs text-brand-rose">
|
</Button>
|
||||||
{t("packages.card.purchased", {
|
{purchased ? (
|
||||||
date: purchased.purchased_at
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
? dateFormatter.format(new Date(purchased.purchased_at))
|
{t("packages.card.purchased", {
|
||||||
: t("packages.card.purchasedUnknown"),
|
date: purchased.purchased_at
|
||||||
})}
|
? dateFormatter.format(new Date(purchased.purchased_at))
|
||||||
</p>
|
: t("packages.card.purchasedUnknown"),
|
||||||
)}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { fetchOnboardingStatus, trackOnboarding } from '../api';
|
import { fetchOnboardingStatus, trackOnboarding } from '../api';
|
||||||
|
import { useAuth } from '../auth/context';
|
||||||
|
|
||||||
export type OnboardingProgress = {
|
export type OnboardingProgress = {
|
||||||
welcomeSeen: boolean;
|
welcomeSeen: boolean;
|
||||||
@@ -13,6 +14,8 @@ export type OnboardingProgress = {
|
|||||||
priceText?: string | null;
|
priceText?: string | null;
|
||||||
isSubscription?: boolean;
|
isSubscription?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
inviteCreated: boolean;
|
||||||
|
brandingConfigured: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnboardingUpdate = Partial<OnboardingProgress> & {
|
type OnboardingUpdate = Partial<OnboardingProgress> & {
|
||||||
@@ -34,6 +37,8 @@ const DEFAULT_PROGRESS: OnboardingProgress = {
|
|||||||
lastStep: null,
|
lastStep: null,
|
||||||
adminAppOpenedAt: null,
|
adminAppOpenedAt: null,
|
||||||
selectedPackage: null,
|
selectedPackage: null,
|
||||||
|
inviteCreated: false,
|
||||||
|
brandingConfigured: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
|
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
|
||||||
@@ -74,13 +79,27 @@ function writeStoredProgress(progress: OnboardingProgress) {
|
|||||||
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
|
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
|
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
|
||||||
const [synced, setSynced] = React.useState(false);
|
const [synced, setSynced] = React.useState(false);
|
||||||
|
const { status } = useAuth();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (status !== 'authenticated') {
|
||||||
|
if (synced) {
|
||||||
|
setSynced(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (synced) {
|
if (synced) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
fetchOnboardingStatus().then((status) => {
|
fetchOnboardingStatus().then((status) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
setSynced(true);
|
setSynced(true);
|
||||||
return;
|
return;
|
||||||
@@ -92,6 +111,8 @@ export function OnboardingProgressProvider({ children }: { children: React.React
|
|||||||
adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
|
adminAppOpenedAt: status.steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
|
||||||
eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
|
eventCreated: Boolean(status.steps.event_created ?? prev.eventCreated),
|
||||||
packageSelected: Boolean(status.steps.selected_packages ?? prev.packageSelected),
|
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);
|
writeStoredProgress(next);
|
||||||
@@ -110,8 +131,16 @@ export function OnboardingProgressProvider({ children }: { children: React.React
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSynced(true);
|
setSynced(true);
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSynced(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [synced]);
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [status, synced]);
|
||||||
|
|
||||||
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
|
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
|
||||||
setProgressState((prev) => {
|
setProgressState((prev) => {
|
||||||
@@ -124,11 +153,39 @@ export function OnboardingProgressProvider({ children }: { children: React.React
|
|||||||
const markStep = React.useCallback((step: OnboardingUpdate) => {
|
const markStep = React.useCallback((step: OnboardingUpdate) => {
|
||||||
const { serverStep, meta, ...rest } = step;
|
const { serverStep, meta, ...rest } = step;
|
||||||
|
|
||||||
setProgress((prev) => ({
|
setProgress((prev) => {
|
||||||
...prev,
|
const derived: Partial<OnboardingProgress> = {};
|
||||||
...rest,
|
|
||||||
lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep,
|
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) {
|
if (serverStep) {
|
||||||
trackOnboarding(serverStep, meta).catch(() => {});
|
trackOnboarding(serverStep, meta).catch(() => {});
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import React from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||||
|
|
||||||
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
|
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
|
||||||
|
|
||||||
@@ -107,24 +108,78 @@ export default function BillingPage() {
|
|||||||
void loadAll();
|
void loadAll();
|
||||||
}, [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(
|
const activeWarnings = React.useMemo(
|
||||||
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
||||||
[activePackage, t, formatDate],
|
[activePackage, t, formatDate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const heroBadge = t('billing.hero.badge', 'Abrechnung');
|
||||||
<AdminLayout
|
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
|
||||||
title={t('billing.title')}
|
const heroSupporting: string[] = [
|
||||||
subtitle={t('billing.subtitle')}
|
activePackage
|
||||||
actions={actions}
|
? 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 && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
|
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
|
||||||
@@ -136,7 +191,7 @@ export default function BillingPage() {
|
|||||||
<BillingSkeleton />
|
<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">
|
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<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')}
|
{t('billing.sections.overview.description')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</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')}
|
{activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -160,7 +215,7 @@ export default function BillingPage() {
|
|||||||
<Alert
|
<Alert
|
||||||
key={warning.id}
|
key={warning.id}
|
||||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
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">
|
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
@@ -204,9 +259,9 @@ export default function BillingPage() {
|
|||||||
<EmptyState message={t('billing.sections.overview.empty')} />
|
<EmptyState message={t('billing.sections.overview.empty')} />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</FrostedCard>
|
||||||
|
|
||||||
<Card className="border-0 bg-white/85 shadow-xl shadow-amber-100/60">
|
<FrostedCard className="border border-white/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||||
@@ -236,9 +291,9 @@ export default function BillingPage() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</FrostedCard>
|
||||||
|
|
||||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
<FrostedCard className="border border-white/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<Sparkles className="h-5 w-5 text-sky-500" />
|
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||||
@@ -282,7 +337,7 @@ export default function BillingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</FrostedCard>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -322,47 +377,47 @@ function TransactionCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<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 ?? '—' })}
|
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{createdLabel}</p>
|
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{createdLabel}</p>
|
||||||
{transaction.checkout_id && (
|
{transaction.checkout_id ? (
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
|
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
{transaction.origin && (
|
{transaction.origin ? (
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
|
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</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">
|
<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">
|
<Badge className="bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200">
|
||||||
{statusText}
|
{statusText}
|
||||||
</Badge>
|
</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)}
|
{formatCurrency(amount, currency)}
|
||||||
</div>
|
</div>
|
||||||
{transaction.tax !== undefined && transaction.tax !== null && (
|
{transaction.tax !== undefined && transaction.tax !== null ? (
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
|
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
{transaction.receipt_url && (
|
{transaction.receipt_url ? (
|
||||||
<a
|
<a
|
||||||
href={transaction.receipt_url}
|
href={transaction.receipt_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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')}
|
{t('billing.sections.transactions.labels.receipt')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,19 +432,19 @@ function InfoCard({
|
|||||||
helper?: string;
|
helper?: string;
|
||||||
tone: 'pink' | 'amber' | 'sky' | 'emerald';
|
tone: 'pink' | 'amber' | 'sky' | 'emerald';
|
||||||
}) {
|
}) {
|
||||||
const toneClass = {
|
const toneBorders: Record<'pink' | 'amber' | 'sky' | 'emerald', string> = {
|
||||||
pink: 'from-pink-50 to-rose-100 text-pink-700',
|
pink: 'border-pink-200/60 shadow-rose-200/30',
|
||||||
amber: 'from-amber-50 to-yellow-100 text-amber-700',
|
amber: 'border-amber-200/60 shadow-amber-200/30',
|
||||||
sky: 'from-sky-50 to-blue-100 text-sky-700',
|
sky: 'border-sky-200/60 shadow-sky-200/30',
|
||||||
emerald: 'from-emerald-50 to-green-100 text-emerald-700',
|
emerald: 'border-emerald-200/60 shadow-emerald-200/30',
|
||||||
}[tone];
|
} as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl border border-white/60 bg-gradient-to-br ${toneClass} p-5 shadow-sm`}>
|
<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-600/90">{label}</span>
|
<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>
|
<div className="mt-3 text-xl font-semibold">{value ?? '--'}</div>
|
||||||
{helper && <p className="mt-2 text-xs text-slate-600/80">{helper}</p>}
|
{helper ? <p className="mt-2 text-xs text-slate-600 dark:text-slate-400">{helper}</p> : null}
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,14 +470,14 @@ function PackageCard({
|
|||||||
warnings?: PackageWarning[];
|
warnings?: PackageWarning[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 && (
|
{warnings.length > 0 && (
|
||||||
<div className="mb-3 space-y-2">
|
<div className="mb-3 space-y-2">
|
||||||
{warnings.map((warning) => (
|
{warnings.map((warning) => (
|
||||||
<Alert
|
<Alert
|
||||||
key={warning.id}
|
key={warning.id}
|
||||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
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">
|
<AlertDescription className="flex items-center gap-2 text-xs">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<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 className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{pkg.package_name}</h3>
|
||||||
<p className="text-xs text-slate-600">
|
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
{formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}
|
{formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}
|
{isActive ? labels.statusActive : labels.statusInactive}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-3" />
|
<Separator className="my-3 dark:border-slate-800/70" />
|
||||||
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-3">
|
<div className="grid gap-2 text-xs text-slate-600 dark:text-slate-400 sm:grid-cols-3">
|
||||||
<span>
|
<span>
|
||||||
{labels.used}: {pkg.used_events}
|
{labels.used}: {pkg.used_events}
|
||||||
</span>
|
</span>
|
||||||
@@ -455,18 +510,18 @@ function PackageCard({
|
|||||||
{labels.expires}: {formatDate(pkg.expires_at)}
|
{labels.expires}: {formatDate(pkg.expires_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ message }: { message: string }) {
|
function EmptyState({ message }: { message: string }) {
|
||||||
return (
|
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">
|
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
||||||
<Sparkles className="h-5 w-5" />
|
<Sparkles className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-600">{message}</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400">{message}</p>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,17 +583,20 @@ function BillingSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{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">
|
<FrostedSurface
|
||||||
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
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">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{Array.from({ length: 4 }).map((__, placeholderIndex) => (
|
{Array.from({ length: 4 }).map((__, placeholderIndex) => (
|
||||||
<div
|
<div
|
||||||
key={placeholderIndex}
|
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>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
CheckCircle2,
|
|
||||||
Circle,
|
|
||||||
QrCode,
|
QrCode,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Package as PackageIcon,
|
Package as PackageIcon,
|
||||||
Loader2,
|
ArrowUpRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { AdminLayout } from '../components/AdminLayout';
|
||||||
import {
|
import {
|
||||||
@@ -36,9 +37,11 @@ import { isAuthError } from '../auth/tokens';
|
|||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import {
|
import {
|
||||||
adminPath,
|
adminPath,
|
||||||
|
ADMIN_HOME_PATH,
|
||||||
ADMIN_EVENT_VIEW_PATH,
|
ADMIN_EVENT_VIEW_PATH,
|
||||||
ADMIN_EVENTS_PATH,
|
ADMIN_EVENTS_PATH,
|
||||||
ADMIN_EVENT_TASKS_PATH,
|
ADMIN_EVENT_PHOTOS_PATH,
|
||||||
|
ADMIN_EVENT_INVITES_PATH,
|
||||||
ADMIN_BILLING_PATH,
|
ADMIN_BILLING_PATH,
|
||||||
ADMIN_SETTINGS_PATH,
|
ADMIN_SETTINGS_PATH,
|
||||||
ADMIN_WELCOME_BASE_PATH,
|
ADMIN_WELCOME_BASE_PATH,
|
||||||
@@ -47,6 +50,7 @@ import {
|
|||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { useOnboardingProgress } from '../onboarding';
|
import { useOnboardingProgress } from '../onboarding';
|
||||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||||
|
import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings';
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
summary: DashboardSummary | null;
|
summary: DashboardSummary | null;
|
||||||
@@ -75,12 +79,23 @@ export default function DashboardPage() {
|
|||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
|
|
||||||
const translate = React.useCallback(
|
const translate = React.useCallback(
|
||||||
(key: string, options?: Record<string, unknown>) => {
|
(key: string, optionsOrFallback?: Record<string, unknown> | string, explicitFallback?: string) => {
|
||||||
const value = t(key, options);
|
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}`) {
|
if (value === `dashboard.${key}`) {
|
||||||
const fallback = i18n.t(`dashboard:${key}`, options);
|
const fallbackValue = i18n.t(`dashboard:${key}`, { defaultValue: fallback, ...(options ?? {}) });
|
||||||
return fallback === `dashboard:${key}` ? value : fallback;
|
if (fallbackValue !== `dashboard:${key}`) {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback !== undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
[t, i18n],
|
[t, i18n],
|
||||||
@@ -237,6 +252,191 @@ export default function DashboardPage() {
|
|||||||
[tc],
|
[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 = (
|
const actions = (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -273,6 +473,16 @@ export default function DashboardPage() {
|
|||||||
<DashboardSkeleton />
|
<DashboardSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<TenantHeroCard
|
||||||
|
badge={heroBadge}
|
||||||
|
title={greetingTitle}
|
||||||
|
description={subtitle}
|
||||||
|
supporting={[heroDescription, heroSupportingCopy]}
|
||||||
|
primaryAction={heroPrimaryAction}
|
||||||
|
secondaryAction={heroSecondaryAction}
|
||||||
|
aside={heroAside}
|
||||||
|
/>
|
||||||
|
|
||||||
{events.length === 0 && (
|
{events.length === 0 && (
|
||||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||||
<CardHeader className="space-y-3">
|
<CardHeader className="space-y-3">
|
||||||
@@ -446,53 +656,26 @@ export default function DashboardPage() {
|
|||||||
description={translate('quickActions.managePackages.description')}
|
description={translate('quickActions.managePackages.description')}
|
||||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||||
/>
|
/>
|
||||||
|
<QuickAction
|
||||||
|
icon={<ArrowUpRight className="h-5 w-5" />}
|
||||||
|
label={marketingDashboardLabel}
|
||||||
|
description={marketingDashboardDescription}
|
||||||
|
onClick={() => window.location.assign('/dashboard')}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ReadinessCard
|
<TenantOnboardingChecklistCard
|
||||||
readiness={readiness}
|
title={onboardingCardTitle}
|
||||||
labels={{
|
description={onboardingCardDescription}
|
||||||
title: translate('readiness.title'),
|
steps={onboardingChecklist}
|
||||||
description: translate('readiness.description'),
|
completedLabel={readinessCompleteLabel}
|
||||||
pending: translate('readiness.pending'),
|
pendingLabel={readinessPendingLabel}
|
||||||
complete: translate('readiness.complete'),
|
completionPercent={onboardingCompletion}
|
||||||
items: {
|
completedCount={completedOnboardingSteps}
|
||||||
event: {
|
totalCount={onboardingChecklist.length}
|
||||||
title: translate('readiness.items.event.title'),
|
emptyCopy={onboardingCompletedCopy}
|
||||||
hint: translate('readiness.items.event.hint'),
|
fallbackActionLabel={onboardingFallbackCta}
|
||||||
},
|
|
||||||
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)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||||
@@ -619,25 +802,6 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
|
|||||||
.slice(0, 4);
|
.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({
|
function LimitUsageRow({
|
||||||
label,
|
label,
|
||||||
summary,
|
summary,
|
||||||
@@ -656,9 +820,9 @@ function LimitUsageRow({
|
|||||||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
<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="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||||
<span>{label}</span>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -674,23 +838,23 @@ function LimitUsageRow({
|
|||||||
: 'bg-emerald-500';
|
: 'bg-emerald-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
<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">
|
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||||
<span>{label}</span>
|
<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}
|
{limit ? usageLabel.replace('{{used}}', `${summary.used}`).replace('{{limit}}', `${limit}`) : unlimitedLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{limit ? (
|
{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
|
<div
|
||||||
className={`h-2 rounded-full transition-all ${barClass}`}
|
className={`h-2 rounded-full transition-all ${barClass}`}
|
||||||
style={{ width: `${Math.max(6, percent)}%` }}
|
style={{ width: `${Math.max(6, percent)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{remaining !== null ? (
|
{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
|
{remainingLabel
|
||||||
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
|
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
|
||||||
.replace('{{limit}}', `${limit}`)}
|
.replace('{{limit}}', `${limit}`)}
|
||||||
@@ -698,7 +862,7 @@ function LimitUsageRow({
|
|||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -732,155 +896,10 @@ function GalleryStatusRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
|
<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">
|
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass}`}>{statusLabel}</span>
|
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass} dark:text-slate-100`}>{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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -898,14 +917,14 @@ function StatCard({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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>
|
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-2xl font-semibold text-slate-900">{value}</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">{hint}</p>}
|
{hint && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{hint}</p>}
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,11 +943,11 @@ function QuickAction({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
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="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">{label}</span>
|
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{label}</span>
|
||||||
<span className="text-xs text-slate-600">{description}</span>
|
<span className="text-xs text-slate-600 dark:text-slate-400">{description}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1009,4 +1028,3 @@ function DashboardSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export type EmotionsSectionProps = {
|
|||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmotionsSection({ embedded = false }: EmotionsSectionProps): JSX.Element {
|
export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
|
||||||
const { t, i18n } = useTranslation('management');
|
const { t, i18n } = useTranslation('management');
|
||||||
|
|
||||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
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');
|
const { t } = useTranslation('management');
|
||||||
return (
|
return (
|
||||||
<AdminLayout title={t('emotions.title')} subtitle={t('emotions.subtitle')}>
|
<AdminLayout title={t('emotions.title')} subtitle={t('emotions.subtitle')}>
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
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 { TasksSection } from './TasksPage';
|
||||||
import { TaskCollectionsSection } from './TaskCollectionsPage';
|
import { TaskCollectionsSection } from './TaskCollectionsPage';
|
||||||
import { EmotionsSection } from './EmotionsPage';
|
import { EmotionsSection } from './EmotionsPage';
|
||||||
@@ -19,7 +22,7 @@ function ensureValidTab(value: string | null): EngagementTab {
|
|||||||
return 'tasks';
|
return 'tasks';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EngagementPage(): JSX.Element {
|
export default function EngagementPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -41,37 +44,91 @@ export default function EngagementPage(): JSX.Element {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const heading = tc('navigation.engagement');
|
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 (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title={heading}
|
title={heading}
|
||||||
subtitle={t('engagement.subtitle', 'Bündle Aufgaben, Vorlagen und Emotionen für deine Events.')}
|
subtitle={t('engagement.subtitle', 'Bündle Aufgaben, Vorlagen und Emotionen für deine Events.')}
|
||||||
>
|
>
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
<TenantHeroCard
|
||||||
<TabsList className="grid w-full grid-cols-3 bg-white/60 shadow-sm">
|
badge={t('engagement.hero.badge', 'Engagement')}
|
||||||
<TabsTrigger value="tasks">{tc('navigation.tasks')}</TabsTrigger>
|
title={heading}
|
||||||
<TabsTrigger value="collections">{tc('navigation.collections')}</TabsTrigger>
|
description={heroDescription}
|
||||||
<TabsTrigger value="emotions">{tc('navigation.emotions')}</TabsTrigger>
|
supporting={heroSupporting}
|
||||||
</TabsList>
|
primaryAction={heroPrimaryAction}
|
||||||
|
secondaryAction={heroSecondaryAction}
|
||||||
|
aside={heroAside}
|
||||||
|
/>
|
||||||
|
|
||||||
<TabsContent value="tasks" className="space-y-6">
|
<FrostedCard className="mt-6 border border-white/20">
|
||||||
<TasksSection
|
<CardHeader className="px-0 pt-0">
|
||||||
embedded
|
<CardTitle className="sr-only">{heading}</CardTitle>
|
||||||
onNavigateToCollections={() => handleTabChange('collections')}
|
</CardHeader>
|
||||||
/>
|
<CardContent className="space-y-6">
|
||||||
</TabsContent>
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
|
<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="collections" className="space-y-6">
|
<TabsContent value="tasks" className="space-y-6">
|
||||||
<TaskCollectionsSection
|
<TasksSection embedded onNavigateToCollections={() => handleTabChange('collections')} />
|
||||||
embedded
|
</TabsContent>
|
||||||
onNavigateToTasks={() => handleTabChange('tasks')}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="emotions" className="space-y-6">
|
<TabsContent value="collections" className="space-y-6">
|
||||||
<EmotionsSection embedded />
|
<TaskCollectionsSection embedded onNavigateToTasks={() => handleTabChange('tasks')} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
|
||||||
|
<TabsContent value="emotions" className="space-y-6">
|
||||||
|
<EmotionsSection embedded />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</FrostedCard>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
EventToolkitTask,
|
EventToolkitTask,
|
||||||
TenantEvent,
|
TenantEvent,
|
||||||
TenantPhoto,
|
TenantPhoto,
|
||||||
TenantEventStats,
|
EventStats,
|
||||||
getEvent,
|
getEvent,
|
||||||
getEventStats,
|
getEventStats,
|
||||||
getEventToolkit,
|
getEventToolkit,
|
||||||
@@ -62,13 +62,13 @@ type ToolkitState = {
|
|||||||
|
|
||||||
type WorkspaceState = {
|
type WorkspaceState = {
|
||||||
event: TenantEvent | null;
|
event: TenantEvent | null;
|
||||||
stats: TenantEventStats | null;
|
stats: EventStats | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
error: string | null;
|
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 { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
@@ -325,7 +325,7 @@ function resolveName(name: TenantEvent['name']): string {
|
|||||||
return 'Event';
|
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 { t } = useTranslation('management');
|
||||||
|
|
||||||
const statusLabel = event.status === 'published'
|
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">
|
<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 className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
|
||||||
<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>
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</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 { t } = useTranslation('management');
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
@@ -780,7 +789,8 @@ function resolveEventType(event: TenantEvent): string {
|
|||||||
if (typeof event.event_type.name === 'string') {
|
if (typeof event.event_type.name === 'string') {
|
||||||
return event.event_type.name;
|
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 '—';
|
return '—';
|
||||||
}
|
}
|
||||||
@@ -801,7 +811,7 @@ function AlertList({ alerts }: { alerts: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarIcon(): JSX.Element {
|
function CalendarIcon() {
|
||||||
return (
|
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">
|
<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" />
|
<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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
<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" />;
|
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import React from 'react';
|
|||||||
|
|
||||||
import EventDetailPage from './EventDetailPage';
|
import EventDetailPage from './EventDetailPage';
|
||||||
|
|
||||||
export default function EventToolkitPage(): JSX.Element {
|
export default function EventToolkitPage() {
|
||||||
return <EventDetailPage mode="toolkit" />;
|
return <EventDetailPage mode="toolkit" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { AdminLayout } from '../components/AdminLayout';
|
||||||
import { getEvents, TenantEvent } from '../api';
|
import { getEvents, TenantEvent } from '../api';
|
||||||
@@ -28,6 +29,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { t: tCommon } = useTranslation('common');
|
const { t: tCommon } = useTranslation('common');
|
||||||
|
|
||||||
const [rows, setRows] = React.useState<TenantEvent[]>([]);
|
const [rows, setRows] = React.useState<TenantEvent[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
@@ -47,28 +49,107 @@ export default function EventsPage() {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const actions = (
|
const translateManagement = React.useCallback(
|
||||||
<>
|
(key: string, fallback?: string, options?: Record<string, unknown>) =>
|
||||||
<Button
|
t(key, { defaultValue: fallback, ...(options ?? {}) }),
|
||||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
[t],
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const translateCommon = React.useCallback(
|
||||||
<AdminLayout
|
(key: string, fallback?: string, options?: Record<string, unknown>) =>
|
||||||
title={t('events.list.title', 'Deine Events')}
|
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
|
||||||
subtitle={t('events.list.subtitle', 'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.')}
|
[tCommon],
|
||||||
actions={actions}
|
);
|
||||||
|
|
||||||
|
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 && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||||
@@ -76,58 +157,70 @@ export default function EventsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
<TenantHeroCard
|
||||||
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
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>
|
<div>
|
||||||
<CardTitle className="text-xl font-semibold text-slate-900">{t('events.list.overview.title', 'Übersicht')}</CardTitle>
|
<CardTitle>{t('events.list.overview.title', 'Übersicht')}</CardTitle>
|
||||||
<CardDescription className="text-slate-600">
|
<CardDescription>
|
||||||
{rows.length === 0
|
{loading
|
||||||
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
|
? t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …')
|
||||||
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: rows.length })}
|
: 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: totalEvents })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-pink-600">
|
<Badge className="bg-pink-100 text-pink-700">{t('events.list.badge.dashboard', 'Tenant Dashboard')}</Badge>
|
||||||
<Sparkles className="h-4 w-4" /> {t('events.list.badge.dashboard', 'Tenant Dashboard')}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : rows.length === 0 ? (
|
) : totalEvents === 0 ? (
|
||||||
<EmptyState onCreate={() => navigate(adminPath('/events/new'))} />
|
<EmptyState onCreate={() => navigate(adminPath('/events/new'))} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{rows.map((event) => (
|
{rows.map((event) => (
|
||||||
<EventCard key={event.id} event={event} translateCommon={tCommon} />
|
<EventCard key={event.id} event={event} translate={translateManagement} translateCommon={translateCommon} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</FrostedCard>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventCard({
|
function EventCard({
|
||||||
event,
|
event,
|
||||||
|
translate,
|
||||||
translateCommon,
|
translateCommon,
|
||||||
}: {
|
}: {
|
||||||
event: TenantEvent;
|
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 slug = event.slug;
|
||||||
const isPublished = event.status === 'published';
|
const isPublished = event.status === 'published';
|
||||||
const photoCount = event.photo_count ?? 0;
|
const photoCount = event.photo_count ?? 0;
|
||||||
const likeCount = event.like_count ?? 0;
|
const likeCount = event.like_count ?? 0;
|
||||||
const limitWarnings = React.useMemo(
|
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],
|
[event.limits, translateCommon],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 && (
|
{limitWarnings.length > 0 && (
|
||||||
<div className="mb-3 space-y-1">
|
<div className="mb-4 space-y-2">
|
||||||
{limitWarnings.map((warning) => (
|
{limitWarnings.map((warning) => (
|
||||||
<Alert
|
<Alert
|
||||||
key={warning.id}
|
key={warning.id}
|
||||||
@@ -143,61 +236,63 @@ function EventCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
|
<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">
|
<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" />
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
{formatDate(event.event_date)}
|
{formatDate(event.event_date)}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-3 py-1 font-medium text-sky-700">
|
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100/90 px-3 py-1 font-medium text-sky-700">
|
||||||
Photos: {photoCount}
|
{translate('events.list.badges.photos', 'Fotos')}: {photoCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1 font-medium text-amber-700">
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100/90 px-3 py-1 font-medium text-amber-700">
|
||||||
Likes: {likeCount}
|
{translate('events.list.badges.likes', 'Likes')}: {likeCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
|
||||||
className={
|
{isPublished
|
||||||
isPublished
|
? translateCommon('events.status.published', 'Veröffentlicht')
|
||||||
? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30'
|
: translateCommon('events.status.draft', 'Entwurf')}
|
||||||
: 'bg-slate-200 text-slate-700'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isPublished ? 'Veroeffentlicht' : 'Entwurf'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
|
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
|
||||||
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
|
<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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
<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>
|
||||||
<Button asChild variant="outline" className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
<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>
|
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
|
||||||
</Button>
|
{translate('events.list.actions.photos', 'Fotos moderieren')}
|
||||||
<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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +300,9 @@ function LoadingState() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
<div
|
<FrostedSurface
|
||||||
key={index}
|
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>
|
</div>
|
||||||
@@ -216,11 +311,11 @@ function LoadingState() {
|
|||||||
|
|
||||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||||
return (
|
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">
|
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
</div>
|
</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>
|
<h3 className="text-lg font-semibold text-slate-900">Noch kein Event angelegt</h3>
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.
|
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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onCreate}
|
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
|
<Plus className="mr-1 h-4 w-4" /> Event erstellen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,20 @@ import AppLogoIcon from '@/components/app-logo-icon';
|
|||||||
|
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
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 {
|
interface LocationState {
|
||||||
from?: Location;
|
from?: Location;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage(): JSX.Element {
|
export default function LoginPage() {
|
||||||
const { status, login } = useAuth();
|
const { status, login } = useAuth();
|
||||||
const { t } = useTranslation('auth');
|
const { t } = useTranslation('auth');
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -25,9 +32,22 @@ export default function LoginPage(): JSX.Element {
|
|||||||
const oauthError = searchParams.get('error');
|
const oauthError = searchParams.get('error');
|
||||||
const oauthErrorDescription = searchParams.get('error_description');
|
const oauthErrorDescription = searchParams.get('error_description');
|
||||||
const rawReturnTo = searchParams.get('return_to');
|
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(
|
const { finalTarget, encodedFinal } = React.useMemo(
|
||||||
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
|
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||||
[rawReturnTo]
|
[fallbackTarget, rawReturnTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolvedErrorMessage = React.useMemo(() => {
|
const resolvedErrorMessage = React.useMemo(() => {
|
||||||
@@ -60,7 +80,6 @@ export default function LoginPage(): JSX.Element {
|
|||||||
return finalTarget;
|
return finalTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = location.state as LocationState | null;
|
|
||||||
if (state?.from) {
|
if (state?.from) {
|
||||||
const from = state.from;
|
const from = state.from;
|
||||||
const search = from.search ?? '';
|
const search = from.search ?? '';
|
||||||
@@ -71,7 +90,7 @@ export default function LoginPage(): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||||
}, [finalTarget, location.state]);
|
}, [finalTarget, state]);
|
||||||
|
|
||||||
const shouldOpenAccountLogin = oauthError === 'login_required';
|
const shouldOpenAccountLogin = oauthError === 'login_required';
|
||||||
const isLoading = status === 'loading';
|
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 (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -1,21 +1,44 @@
|
|||||||
import React from 'react';
|
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 { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
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 { status, login } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
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 rawReturnTo = searchParams.get('return_to');
|
||||||
const { finalTarget, encodedFinal } = React.useMemo(
|
const { finalTarget, encodedFinal } = React.useMemo(
|
||||||
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
|
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||||
[rawReturnTo]
|
[fallbackTarget, rawReturnTo]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [hasStarted, setHasStarted] = React.useState(false);
|
const [hasStarted, setHasStarted] = React.useState(false);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function extractFieldErrors(error: unknown): FieldErrors {
|
|||||||
const DEFAULT_LOCALES = ['de', 'en'];
|
const DEFAULT_LOCALES = ['de', 'en'];
|
||||||
const AUTO_LOCALE_OPTION = '__auto__';
|
const AUTO_LOCALE_OPTION = '__auto__';
|
||||||
|
|
||||||
export default function ProfilePage(): JSX.Element {
|
export default function ProfilePage() {
|
||||||
const { t } = useTranslation(['settings', 'common']);
|
const { t } = useTranslation(['settings', 'common']);
|
||||||
const { refreshProfile } = useAuth();
|
const { refreshProfile } = useAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Switch } from '@/components/ui/switch';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
|
||||||
import { AdminLayout } from '../components/AdminLayout';
|
import { AdminLayout } from '../components/AdminLayout';
|
||||||
|
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
||||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
|
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
|
||||||
@@ -33,6 +34,50 @@ export default function SettingsPage() {
|
|||||||
const [notificationError, setNotificationError] = React.useState<string | null>(null);
|
const [notificationError, setNotificationError] = React.useState<string | null>(null);
|
||||||
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | 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() {
|
function handleLogout() {
|
||||||
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
|
const targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
|
||||||
let marketingUrl = buildMarketingLoginUrl(targetPath);
|
let marketingUrl = buildMarketingLoginUrl(targetPath);
|
||||||
@@ -55,23 +100,22 @@ export default function SettingsPage() {
|
|||||||
})();
|
})();
|
||||||
}, [t]);
|
}, [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 (
|
return (
|
||||||
<AdminLayout
|
<AdminLayout
|
||||||
title="Einstellungen"
|
title="Einstellungen"
|
||||||
subtitle="Passe das Erscheinungsbild deines Dashboards an und verwalte deine Session."
|
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>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
|
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
|
||||||
@@ -114,9 +158,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</CardContent>
|
</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>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||||
<AlertTriangle className="h-5 w-5 text-pink-500" />
|
<AlertTriangle className="h-5 w-5 text-pink-500" />
|
||||||
@@ -136,9 +180,9 @@ export default function SettingsPage() {
|
|||||||
{loadingNotifications ? (
|
{loadingNotifications ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
<div
|
<FrostedSurface
|
||||||
key={index}
|
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>
|
</div>
|
||||||
@@ -173,11 +217,11 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
saving={savingNotifications}
|
saving={savingNotifications}
|
||||||
translate={t}
|
translate={translateNotification}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</FrostedCard>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,7 +243,7 @@ function NotificationPreferencesForm({
|
|||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onSave: () => Promise<void>;
|
onSave: () => Promise<void>;
|
||||||
saving: boolean;
|
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 items = React.useMemo(() => buildPreferenceMeta(translate), [translate]);
|
||||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
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;
|
const checked = preferences[item.key] ?? defaults[item.key] ?? true;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-semibold text-slate-900">{item.label}</h3>
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{item.label}</h3>
|
||||||
<p className="text-sm text-slate-600">{item.description}</p>
|
<p className="text-sm text-slate-600 dark:text-slate-400">{item.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={(value) => onChange({ ...preferences, [item.key]: Boolean(value) })}
|
onCheckedChange={(value) => onChange({ ...preferences, [item.key]: Boolean(value) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FrostedSurface>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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')}
|
{saving ? 'Speichern...' : translate('settings.notifications.actions.save', 'Speichern')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={onReset} disabled={saving}>
|
<Button variant="ghost" onClick={onReset} disabled={saving}>
|
||||||
{translate('settings.notifications.actions.reset', 'Auf Standard setzen')}
|
{translate('settings.notifications.actions.reset', 'Auf Standard setzen')}
|
||||||
</Button>
|
</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.')}
|
{translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreferenceMeta(
|
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 }> {
|
): Array<{ key: keyof NotificationPreferences; label: string; description: string }> {
|
||||||
const map = [
|
const map = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import type { Locale } from 'date-fns';
|
||||||
import { de, enGB } from 'date-fns/locale';
|
import { de, enGB } from 'date-fns/locale';
|
||||||
import { Layers, Library, Loader2, Plus } from 'lucide-react';
|
import { Layers, Library, Loader2, Plus } from 'lucide-react';
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ export type TaskCollectionsSectionProps = {
|
|||||||
onNavigateToTasks?: () => void;
|
onNavigateToTasks?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps): JSX.Element {
|
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t, i18n } = useTranslation('management');
|
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 navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export type TasksSectionProps = {
|
|||||||
onNavigateToCollections?: () => void;
|
onNavigateToCollections?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps): JSX.Element {
|
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
|
|
||||||
@@ -300,7 +300,9 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
|||||||
<Label htmlFor="task-priority">Priorität</Label>
|
<Label htmlFor="task-priority">Priorität</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.priority ?? 'medium'}
|
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">
|
<SelectTrigger id="task-priority">
|
||||||
<SelectValue placeholder="Priorität wählen" />
|
<SelectValue placeholder="Priorität wählen" />
|
||||||
@@ -309,6 +311,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
|||||||
<SelectItem value="low">Niedrig</SelectItem>
|
<SelectItem value="low">Niedrig</SelectItem>
|
||||||
<SelectItem value="medium">Mittel</SelectItem>
|
<SelectItem value="medium">Mittel</SelectItem>
|
||||||
<SelectItem value="high">Hoch</SelectItem>
|
<SelectItem value="high">Hoch</SelectItem>
|
||||||
|
<SelectItem value="urgent">Dringend</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 navigate = useNavigate();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { t: tc } = useTranslation('common');
|
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' },
|
low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
|
||||||
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
|
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
|
||||||
high: { label: 'Hoch', className: 'bg-rose-50 text-rose-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];
|
const { label, className } = mapping[priority];
|
||||||
return <Badge className={`border-none ${className}`}>{label}</Badge>;
|
return <Badge className={`border-none ${className}`}>{label}</Badge>;
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function DesignerCanvas({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
canvas.selection = !readOnly;
|
canvas.selection = !readOnly;
|
||||||
canvas.forEachObject((object) => {
|
canvas.forEachObject((object: fabric.Object) => {
|
||||||
object.set({
|
object.set({
|
||||||
selectable: !readOnly,
|
selectable: !readOnly,
|
||||||
hoverCursor: readOnly ? 'default' : 'move',
|
hoverCursor: readOnly ? 'default' : 'move',
|
||||||
@@ -216,11 +216,12 @@ export function DesignerCanvas({
|
|||||||
onSelect(active.elementId);
|
onSelect(active.elementId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectionCleared = (event?: fabric.IEvent<MouseEvent>) => {
|
const handleSelectionCleared = (event?: unknown) => {
|
||||||
|
const pointerEvent = event as { e?: MouseEvent } | undefined;
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const triggeredByPointer = Boolean(event?.e);
|
const triggeredByPointer = Boolean(pointerEvent?.e);
|
||||||
if (!triggeredByPointer && requestedSelectionRef.current) {
|
if (!triggeredByPointer && requestedSelectionRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -245,14 +246,16 @@ export function DesignerCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Manual collision check: Calculate overlap and push vertically
|
// Manual collision check: Calculate overlap and push vertically
|
||||||
const otherObjects = canvas.getObjects().filter(obj => obj !== target && (obj as FabricObjectWithId).elementId);
|
const otherObjects = canvas
|
||||||
otherObjects.forEach(other => {
|
.getObjects()
|
||||||
|
.filter((obj): obj is FabricObjectWithId => obj !== target && Boolean((obj as FabricObjectWithId).elementId));
|
||||||
|
otherObjects.forEach((other) => {
|
||||||
const otherBounds = other.getBoundingRect();
|
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 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));
|
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) {
|
if (overlapX > 0 && overlapY > 0) {
|
||||||
// Push down by 120px if overlap (massive spacing für größeren QR-Code)
|
// 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
|
const match = canvas
|
||||||
.getObjects()
|
.getObjects()
|
||||||
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
|
.find((object): object is FabricObjectWithId => (object as FabricObjectWithId).elementId === selectedId);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
canvas.setActiveObject(match);
|
canvas.setActiveObject(match);
|
||||||
@@ -483,7 +486,7 @@ export async function renderFabricLayout(
|
|||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
logoDataUrl,
|
logoDataUrl,
|
||||||
readOnly,
|
readOnly,
|
||||||
}, abortController.signal),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fabricObjects = await Promise.all(objectPromises);
|
const fabricObjects = await Promise.all(objectPromises);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export async function withFabricCanvas<T>(
|
|||||||
await renderFabricLayout(canvas, {
|
await renderFabricLayout(canvas, {
|
||||||
...options,
|
...options,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
selectedId: null,
|
|
||||||
});
|
});
|
||||||
return await handler(canvas, canvasElement);
|
return await handler(canvas, canvasElement);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,7 +101,9 @@ export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<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');
|
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
|
||||||
if (!printWindow) {
|
if (!printWindow) {
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ import { type SharedData } from '@/types';
|
|||||||
|
|
||||||
export default function AppLogo() {
|
export default function AppLogo() {
|
||||||
const { translations } = usePage<SharedData>().props;
|
const { translations } = usePage<SharedData>().props;
|
||||||
const areaLabel =
|
const navigationTranslations =
|
||||||
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -19,7 +19,15 @@ export function DashboardLanguageSwitcher() {
|
|||||||
const { locale, supportedLocales, translations } = page.props;
|
const { locale, supportedLocales, translations } = page.props;
|
||||||
const locales = supportedLocales && supportedLocales.length > 0 ? supportedLocales : ['de', 'en'];
|
const locales = supportedLocales && supportedLocales.length > 0 ? supportedLocales : ['de', 'en'];
|
||||||
const activeLocale = locales.includes(locale as string) && typeof locale === 'string' ? locale : locales[0];
|
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 label = languageCopy.label ?? 'Sprache';
|
||||||
const changeLabel = languageCopy.change ?? 'Sprache wechseln';
|
const changeLabel = languageCopy.change ?? 'Sprache wechseln';
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import { Link, usePage } from '@inertiajs/react';
|
|||||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
||||||
const page = usePage<SharedData>();
|
const page = usePage<SharedData>();
|
||||||
const { translations } = page.props;
|
const { translations } = page.props;
|
||||||
const groupLabel =
|
const navigationTranslations =
|
||||||
(translations?.dashboard?.navigation?.group_label as string | undefined) ?? 'Kundenbereich';
|
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 (
|
return (
|
||||||
<SidebarGroup className="px-2 py-0">
|
<SidebarGroup className="px-2 py-0">
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
/* Language and Environment */
|
/* Language and Environment */
|
||||||
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
"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. */
|
"lib": ["DOM", "DOM.Iterable", "ESNext"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
"jsx": "react-jsx", /* Specify what JSX code is generated. */
|
||||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
// "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'. */
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
@@ -110,8 +110,19 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./resources/js/*"]
|
"@/*": ["./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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user