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