die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.

This commit is contained in:
Codex Agent
2025-11-04 16:14:17 +01:00
parent 92e64c361a
commit fe380689fb
63 changed files with 4239 additions and 1142 deletions

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { useForm } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const ProfileAccount = () => {
const { data, setData, post, processing, errors } = useForm({
name: '',
email: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/profile/account');
};
return (
<div className="container mx-auto py-8">
<Card>
<CardHeader>
<CardTitle>Account bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} />
{errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={data.email} onChange={(e) => setData('email', e.target.value)} />
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
</div>
<Button type="submit" disabled={processing}>Speichern</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default ProfileAccount;

View File

@@ -1,38 +1,375 @@
import React from 'react';
import { usePage } from '@inertiajs/react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import Account from './Account';
import Orders from './Orders';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useMemo, useRef } from 'react';
import { Transition } from '@headlessui/react';
import { Head, Form, Link, usePage } from '@inertiajs/react';
import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react';
const ProfileIndex = () => {
const { user } = usePage().props as any;
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import InputError from '@/components/input-error';
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem, type SharedData } from '@/types';
return (
<div className="container mx-auto py-8 space-y-6">
<Card>
<CardHeader>
<CardTitle>Mein Profil</CardTitle>
</CardHeader>
<CardContent>
<p>Hallo, {user.name}!</p>
<p>Email: {user.email}</p>
</CardContent>
</Card>
<Tabs defaultValue="account" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="orders">Bestellungen</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Account />
</TabsContent>
<TabsContent value="orders">
<Orders />
</TabsContent>
</Tabs>
</div>
);
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Separator } from '@/components/ui/separator';
import { send as resendVerificationRoute } from '@/routes/verification';
type ProfilePageProps = {
userData: {
id: number;
name: string;
email: string;
username?: string | null;
preferredLocale?: string | null;
emailVerifiedAt: string | null;
mustVerifyEmail: boolean;
};
tenant: {
id: number;
name: string;
eventCreditsBalance: number | null;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
name: string;
price: number | null;
expiresAt: string | null;
remainingEvents: number | null;
} | null;
} | null;
purchases: Array<{
id: number;
packageName: string;
price: number | null;
purchasedAt: string | null;
type: string | null;
provider: string | null;
}>;
};
export default ProfileIndex;
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Profil',
href: '/profile',
},
];
export default function ProfileIndex() {
const { userData, tenant, purchases, supportedLocales, locale } = usePage<SharedData & ProfilePageProps>().props;
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
}), [locale]);
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}), [locale]);
const registrationDate = useMemo(() => {
if (!userData.emailVerifiedAt) {
return null;
}
try {
return dateFormatter.format(new Date(userData.emailVerifiedAt));
} catch (error) {
return null;
}
}, [userData.emailVerifiedAt, dateFormatter]);
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
const formatPrice = (price: number | null) => (price === null ? '—' : currencyFormatter.format(price));
const localeOptions = (supportedLocales ?? ['de', 'en']).map((value) => ({
label: value.toUpperCase(),
value,
}));
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profil" />
<div className="flex flex-1 flex-col gap-6 pb-12">
<Card className="border-border/60 bg-gradient-to-br from-background to-muted/50 shadow-sm">
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-2xl font-semibold">Hallo, {userData.name || userData.email}</CardTitle>
<p className="text-sm text-muted-foreground">
Hier verwaltest du deine Zugangsdaten, sprichst mit uns in deiner Lieblingssprache und behältst alle Buchungen im Blick.
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
<Badge variant={userData.emailVerifiedAt ? 'secondary' : 'outline'} className="flex items-center gap-2">
{userData.emailVerifiedAt ? <CheckCircle2 className="size-4 text-emerald-500" /> : <MailWarning className="size-4 text-amber-500" />}
{userData.emailVerifiedAt ? 'E-Mail bestätigt' : 'Bestätigung ausstehend'}
</Badge>
{registrationDate && (
<p className="text-xs text-muted-foreground">Aktiv seit {registrationDate}</p>
)}
</div>
</CardHeader>
{!userData.emailVerifiedAt && userData.mustVerifyEmail && (
<CardContent>
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-900 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-50">
<MailWarning className="size-5" />
<div>
<AlertTitle>E-Mail-Bestätigung ausstehend</AlertTitle>
<AlertDescription>
Bestätige deine E-Mail-Adresse, damit wir dich über Uploads, Rechnungen und Event-Updates informieren können.
<div className="mt-3">
<Link href={resendVerificationRoute()} as="button" method="post" className="text-sm font-medium underline underline-offset-4">
Bestätigungslink erneut senden
</Link>
</div>
</AlertDescription>
</div>
</Alert>
</CardContent>
)}
</Card>
<div className="grid gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<AccountForm userData={userData} localeOptions={localeOptions} />
</Card>
<Card>
<PasswordForm />
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle>Abonnements &amp; Pakete</CardTitle>
<p className="text-sm text-muted-foreground">
Hier findest du die wichtigsten Daten zu deinem aktuellen Paket und deinen letzten Buchungen.
</p>
</div>
{tenant?.activePackage ? (
<Badge variant="outline" className="flex items-center gap-2">
<CalendarClock className="size-4" /> Läuft bis {formatDate(tenant.activePackage.expiresAt)}
</Badge>
) : (
<Badge variant="outline">Kein aktives Paket</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
{tenant?.activePackage ? (
<div className="grid gap-3 rounded-lg border border-dashed border-muted-foreground/30 bg-muted/30 p-4 md:grid-cols-2">
<div>
<h3 className="text-base font-medium leading-tight">{tenant.activePackage.name}</h3>
<p className="text-xs text-muted-foreground">{tenant.eventCreditsBalance ?? 0} Credits verfügbar · {tenant.activePackage.remainingEvents ?? 0} Events inklusive</p>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Status</span>
<span className="font-medium capitalize">{tenant.subscriptionStatus ?? 'aktiv'}</span>
</div>
<div className="flex items-center justify-between">
<span>Verlängerung</span>
<span>{formatDate(tenant.subscriptionExpiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>Preis</span>
<span>{formatPrice(tenant.activePackage.price)}</span>
</div>
</div>
</div>
) : (
<Alert className="border border-dashed border-muted-foreground/40 bg-muted/20">
<AlertDescription className="text-sm text-muted-foreground">
Du hast aktuell kein aktives Paket. Sichere dir jetzt Credits oder ein Komplettpaket, um neue Events zu planen.
</AlertDescription>
</Alert>
)}
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<ReceiptText className="size-4" /> Letzte Buchungen
</div>
{purchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-sm text-muted-foreground text-center">
Noch keine Buchungen vorhanden. Schaue im Dashboard vorbei, um passende Pakete zu finden.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead className="hidden sm:table-cell">Typ</TableHead>
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
<TableHead className="hidden md:table-cell">Datum</TableHead>
<TableHead className="text-right">Preis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
<TableCell className="text-right font-medium">{formatPrice(purchase.price)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
</AppLayout>
);
}
function AccountForm({ userData, localeOptions }: { userData: ProfilePageProps['userData']; localeOptions: Array<{ label: string; value: string }> }) {
return (
<>
<CardHeader>
<CardTitle>Profilinformationen</CardTitle>
<p className="text-sm text-muted-foreground">Aktualisiere deine Kontaktdaten und die Standardsprache für E-Mails und Oberfläche.</p>
</CardHeader>
<CardContent>
<Form
{...ProfileController.update.form()}
options={{ preserveScroll: true }}
className="space-y-6"
>
{({ processing, recentlySuccessful, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Vollständiger Name</Label>
<Input id="name" name="name" defaultValue={userData.name ?? ''} autoComplete="name" placeholder="Max Mustermann" />
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input id="email" type="email" name="email" defaultValue={userData.email ?? ''} autoComplete="username" />
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="username">Benutzername</Label>
<Input id="username" name="username" defaultValue={userData.username ?? ''} autoComplete="username" placeholder="mein-eventname" />
<InputError message={errors.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="preferred_locale">Bevorzugte Sprache</Label>
<select
id="preferred_locale"
name="preferred_locale"
defaultValue={userData.preferredLocale ?? localeOptions[0]?.value ?? 'de'}
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{localeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<InputError message={errors.preferred_locale} />
</div>
<CardFooter className="px-0">
<div className="flex items-center gap-4">
<Button disabled={processing}>Änderungen speichern</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-muted-foreground">Gespeichert</p>
</Transition>
</div>
</CardFooter>
</>
)}
</Form>
</CardContent>
</>
);
}
function PasswordForm() {
const passwordInputRef = useRef<HTMLInputElement>(null);
const currentPasswordInputRef = useRef<HTMLInputElement>(null);
return (
<>
<CardHeader>
<CardTitle>Sicherheit &amp; Passwort</CardTitle>
<p className="text-sm text-muted-foreground">Vergebe ein starkes Passwort, um dein Konto bestmöglich zu schützen.</p>
</CardHeader>
<CardContent>
<Form
{...PasswordController.update.form()}
options={{ preserveScroll: true }}
resetOnError={['password', 'password_confirmation', 'current_password']}
resetOnSuccess
onError={(errors) => {
if (errors.password) {
passwordInputRef.current?.focus();
}
if (errors.current_password) {
currentPasswordInputRef.current?.focus();
}
}}
className="space-y-6"
>
{({ errors, processing, recentlySuccessful }) => (
<>
<div className="grid gap-2">
<Label htmlFor="current_password">Aktuelles Passwort</Label>
<Input id="current_password" name="current_password" type="password" ref={currentPasswordInputRef} autoComplete="current-password" />
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Neues Passwort</Label>
<Input id="password" name="password" type="password" ref={passwordInputRef} autoComplete="new-password" />
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Bestätigung</Label>
<Input id="password_confirmation" name="password_confirmation" type="password" autoComplete="new-password" />
<InputError message={errors.password_confirmation} />
</div>
<CardFooter className="px-0">
<div className="flex items-center gap-4">
<Button disabled={processing}>Passwort speichern</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-muted-foreground">Aktualisiert</p>
</Transition>
</div>
</CardFooter>
</>
)}
</Form>
</CardContent>
</>
);
}

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { usePage } from '@inertiajs/react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns';
interface Purchase {
id: number;
created_at: string;
package: {
name: string;
price: number;
};
status: string;
}
const ProfileOrders = () => {
const page = usePage<{ purchases?: Purchase[] }>();
const purchases = page.props.purchases ?? [];
return (
<div className="container mx-auto py-8">
<Card>
<CardHeader>
<CardTitle>Bestellungen</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead>Preis</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell>{purchase.package.name}</TableCell>
<TableCell>{purchase.package.price} </TableCell>
<TableCell>{format(new Date(purchase.created_at), 'dd.MM.yyyy')}</TableCell>
<TableCell>
<Badge variant={purchase.status === 'completed' ? 'default' : 'secondary'}>
{purchase.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{purchases.length === 0 && (
<p className="text-center text-muted-foreground py-8">Keine Bestellungen gefunden.</p>
)}
</CardContent>
</Card>
</div>
);
};
export default ProfileOrders;

View File

@@ -1,4 +1,5 @@
import { FormEvent, useEffect, useState } from 'react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Head, useForm } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import InputError from '@/components/input-error';
@@ -8,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth-layout';
import AppLayout from '@/layouts/app/AppLayout';
import { register } from '@/routes';
import { request } from '@/routes/password';
import { LoaderCircle } from 'lucide-react';
@@ -19,12 +21,15 @@ interface LoginProps {
export default function Login({ status, canResetPassword }: LoginProps) {
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const [rawReturnTo, setRawReturnTo] = useState<string | null>(null);
const [isRedirectingToGoogle, setIsRedirectingToGoogle] = useState(false);
const { t } = useTranslation('auth');
const { data, setData, post, processing, errors, clearErrors } = useForm({
email: '',
login: '',
password: '',
remember: false,
return_to: '',
});
const submit = (e: FormEvent<HTMLFormElement>) => {
@@ -38,6 +43,19 @@ export default function Login({ status, canResetPassword }: LoginProps) {
const errorKeys = Object.keys(errors);
const hasErrors = errorKeys.length > 0;
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const searchParams = new URLSearchParams(window.location.search);
setRawReturnTo(searchParams.get('return_to'));
}, []);
useEffect(() => {
setData('return_to', rawReturnTo ?? '');
}, [rawReturnTo, setData]);
useEffect(() => {
if (!hasTriedSubmit) {
return;
@@ -56,42 +74,70 @@ export default function Login({ status, canResetPassword }: LoginProps) {
}
}, [errors, hasTriedSubmit]);
const googleHref = useMemo(() => {
if (!rawReturnTo) {
return '/event-admin/auth/google';
}
const params = new URLSearchParams({
return_to: rawReturnTo,
});
return `/event-admin/auth/google?${params.toString()}`;
}, [rawReturnTo]);
const handleGoogleLogin = () => {
if (typeof window === 'undefined') {
return;
}
setIsRedirectingToGoogle(true);
window.location.href = googleHref;
};
return (
<AuthLayout title={t('login.title')} description={t('login.description')}>
<AuthLayout
title={t('login.title')}
description={t('login.description')}
name={t('login.brand', t('login.title'))}
logoSrc="/logo-transparent-lg.png"
logoAlt={t('login.logo_alt', 'Die Fotospiel.App')}
>
<Head title={t('login.title')} />
<form
onSubmit={submit}
className="relative flex flex-col gap-6 overflow-hidden rounded-3xl border border-gray-200/70 bg-white/80 p-6 shadow-xl shadow-rose-200/40 backdrop-blur-sm transition dark:border-gray-800/80 dark:bg-gray-900/70"
>
<input type="hidden" name="return_to" value={data.return_to ?? ''} />
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-pink-400/80 via-rose-400/70 to-sky-400/70" aria-hidden />
<div className="grid gap-6 pt-2 sm:pt-4">
<div className="grid gap-2">
<Label htmlFor="email" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
<Label htmlFor="login" className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{t('login.email')}
</Label>
<Input
id="email"
type="email"
name="email"
id="login"
type="text"
name="login"
required
autoFocus
tabIndex={1}
autoComplete="email"
autoComplete="username"
placeholder={t('login.email_placeholder')}
value={data.email}
value={data.login}
onChange={(e) => {
setData('email', e.target.value);
if (errors.email) {
clearErrors('email');
setData('login', e.target.value);
if (errors.login) {
clearErrors('login');
}
}}
className="h-12 rounded-xl border-gray-200/80 bg-white/90 px-4 text-base shadow-inner shadow-gray-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100"
/>
<InputError
key={`error-email`}
message={errors.email}
key={`error-login`}
message={errors.login}
className="text-sm font-medium text-rose-600 dark:text-rose-400"
/>
</div>
@@ -178,6 +224,32 @@ export default function Login({ status, canResetPassword }: LoginProps) {
)}
</div>
<div className="space-y-3">
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
{t('login.oauth_divider', 'oder')}
<span className="h-px flex-1 bg-gray-200 dark:bg-gray-800" aria-hidden />
</div>
<Button
type="button"
variant="outline"
onClick={handleGoogleLogin}
disabled={processing || isRedirectingToGoogle}
className="flex h-12 w-full items-center justify-center gap-3 rounded-xl border-gray-200/80 bg-white/90 text-sm font-semibold text-gray-700 shadow-inner shadow-gray-200/40 transition hover:bg-white dark:border-gray-800/70 dark:bg-gray-900/60 dark:text-gray-100 dark:hover:bg-gray-900/80"
>
{isRedirectingToGoogle ? (
<LoaderCircle className="h-5 w-5 animate-spin" aria-hidden />
) : (
<GoogleIcon className="h-5 w-5" />
)}
<span>{t('login.google_cta')}</span>
</Button>
<p className="text-center text-xs text-muted-foreground dark:text-gray-300">
{t('login.google_helper', 'Nutze dein Google-Konto, um dich sicher bei der Eventverwaltung anzumelden.')}
</p>
</div>
<div className="rounded-2xl border border-gray-200/60 bg-gray-50/80 p-4 text-center text-sm text-muted-foreground shadow-inner dark:border-gray-800/70 dark:bg-gray-900/60">
{t('login.no_account')}{' '}
<TextLink
@@ -192,3 +264,28 @@ export default function Login({ status, canResetPassword }: LoginProps) {
</AuthLayout>
);
}
Login.layout = (page: ReactNode) => <AppLayout header={<></>}>{page}</AppLayout>;
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
fill="#4285F4"
d="M23.52 12.272c0-.851-.076-1.67-.217-2.455H12v4.639h6.44a5.51 5.51 0 0 1-2.393 3.622v3.01h3.88c2.271-2.093 3.593-5.18 3.593-8.816Z"
/>
<path
fill="#34A853"
d="M12 24c3.24 0 5.957-1.073 7.943-2.912l-3.88-3.01c-1.073.72-2.446 1.147-4.063 1.147-3.124 0-5.773-2.111-6.717-4.954H1.245v3.11C3.221 21.64 7.272 24 12 24Z"
/>
<path
fill="#FBBC05"
d="M5.283 14.27a7.2 7.2 0 0 1 0-4.54V6.62H1.245a11.996 11.996 0 0 0 0 10.76l4.038-3.11Z"
/>
<path
fill="#EA4335"
d="M12 4.75c1.761 0 3.344.606 4.595 1.794l3.447-3.447C17.957 1.012 15.24 0 12 0 7.272 0 3.221 2.36 1.245 6.62l4.038 3.11C6.227 6.861 8.876 4.75 12 4.75Z"
/>
</svg>
);
}

View File

@@ -1,8 +1,85 @@
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import { useMemo, useState } from 'react';
import { AlertTriangle, CalendarDays, Camera, ClipboardList, Package, Sparkles, TrendingUp, UserRound, Key } from 'lucide-react';
import { Head, Link, router, usePage } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { dashboard } from '@/routes';
import { type BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';
import { edit as passwordSettings } from '@/routes/password';
import profileRoutes from '@/routes/profile';
import { send as resendVerificationRoute } from '@/routes/verification';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
type DashboardMetrics = {
total_events: number;
active_events: number;
published_events: number;
events_with_tasks: number;
upcoming_events: number;
new_photos: number;
task_progress: number;
credit_balance: number | null;
active_package: {
name: string;
expires_at: string | null;
remaining_events: number | null;
} | null;
};
type DashboardEvent = {
id: number;
name: string;
slug: string | null;
status: string | null;
isActive: boolean;
date: string | null;
photosCount: number;
tasksCount: number;
joinTokensCount: number;
};
type DashboardPurchase = {
id: number;
packageName: string;
price: number | null;
purchasedAt: string | null;
type: string | null;
provider: string | null;
};
type TenantSummary = {
id: number;
name: string;
eventCreditsBalance: number | null;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
name: string;
price: number | null;
expiresAt: string | null;
remainingEvents: number | null;
} | null;
} | null;
type DashboardPageProps = {
metrics: DashboardMetrics | null;
upcomingEvents: DashboardEvent[];
recentPurchases: DashboardPurchase[];
latestPurchase: DashboardPurchase | null;
tenant: TenantSummary;
emailVerification: {
mustVerify: boolean;
verified: boolean;
};
};
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -12,24 +89,393 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function Dashboard() {
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage<SharedData & DashboardPageProps>().props;
const [verificationSent, setVerificationSent] = useState(false);
const [sendingVerification, setSendingVerification] = useState(false);
const needsEmailVerification = emailVerification.mustVerify && !emailVerification.verified;
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
}), [locale]);
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}), [locale]);
const taskProgress = metrics?.task_progress ?? 0;
const checklistItems = [
{
key: 'verify-email',
title: 'E-Mail-Adresse bestätigen',
description: 'Bestätige deine Adresse, um Einladungen zu versenden und Benachrichtigungen zu erhalten.',
done: !needsEmailVerification,
},
{
key: 'create-event',
title: 'Dein erstes Event erstellen',
description: 'Starte mit einem Event-Blueprint und passe Agenda, Uploadregeln und Branding an.',
done: (metrics?.total_events ?? 0) > 0,
},
{
key: 'publish-event',
title: 'Event veröffentlichen',
description: 'Schalte dein Event frei, damit Gäste über den Link Fotos hochladen können.',
done: (metrics?.published_events ?? 0) > 0,
},
{
key: 'invite-guests',
title: 'Gästelink teilen',
description: 'Nutze QR-Code oder Link, um Gäste einzuladen und erste Uploads zu sammeln.',
done: upcomingEvents.some((event) => event.joinTokensCount > 0),
},
{
key: 'collect-photos',
title: 'Fotos sammeln',
description: 'Spare Zeit bei der Nachbereitung: neue Uploads erscheinen direkt in deinem Event.',
done: (metrics?.new_photos ?? 0) > 0,
},
];
const quickActions = [
{
key: 'tenant-admin',
label: 'Event-Admin öffnen',
description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.',
href: '/event-admin',
icon: Sparkles,
},
{
key: 'profile',
label: 'Profil verwalten',
description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.',
href: profileRoutes.index().url,
icon: UserRound,
},
{
key: 'password',
label: 'Passwort aktualisieren',
description: 'Sichere dein Konto mit einem aktuellen Passwort.',
href: passwordSettings().url,
icon: Key,
},
{
key: 'packages',
label: 'Pakete entdecken',
description: 'Mehr Events oder Speicher buchen du bleibst flexibel.',
href: `/${locale ?? 'de'}/packages`,
icon: Package,
},
];
const stats = [
{
key: 'active-events',
label: 'Aktive Events',
value: metrics?.active_events ?? 0,
description: metrics?.active_events
? 'Events sind live und für Gäste sichtbar.'
: 'Noch kein Event veröffentlicht starte heute!',
icon: CalendarDays,
},
{
key: 'upcoming-events',
label: 'Bevorstehende Events',
value: metrics?.upcoming_events ?? 0,
description: metrics?.upcoming_events
? 'Planung läuft behalte Checklisten und Aufgaben im Blick.'
: 'Lass dich vom Assistenten beim Planen unterstützen.',
icon: TrendingUp,
},
{
key: 'new-photos',
label: 'Neue Fotos (7 Tage)',
value: metrics?.new_photos ?? 0,
description: metrics && metrics.new_photos > 0
? 'Frisch eingetroffene Erinnerungen deiner Gäste.'
: 'Sammle erste Uploads über QR-Code oder Direktlink.',
icon: Camera,
},
{
key: 'credit-balance',
label: 'Event Credits',
value: tenant?.eventCreditsBalance ?? 0,
description: tenant?.eventCreditsBalance
? 'Verfügbare Credits für neue Events.'
: 'Buche Pakete oder Credits, um weitere Events zu planen.',
icon: Package,
extra: metrics?.active_package?.remaining_events ?? null,
},
{
key: 'task-progress',
label: 'Event-Checkliste',
value: `${taskProgress}%`,
description: taskProgress > 0
? 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.'
: 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.',
icon: ClipboardList,
},
];
const handleResendVerification = () => {
setSendingVerification(true);
setVerificationSent(false);
router.post(resendVerificationRoute(), {}, {
preserveScroll: true,
onSuccess: () => setVerificationSent(true),
onFinish: () => setSendingVerification(false),
});
};
const renderPrice = (price: number | null) => {
if (price === null) {
return '—';
}
try {
return currencyFormatter.format(price);
} catch (error) {
return `${price.toFixed(2)}`;
}
};
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="mt-8 flex flex-1 flex-col gap-8 pb-16">
{needsEmailVerification && (
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-950 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100">
<AlertTriangle className="size-5" />
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle>Bitte bestätige deine E-Mail-Adresse</AlertTitle>
<AlertDescription>
Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist.
Prüfe dein Postfach oder fordere einen neuen Link an.
</AlertDescription>
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button size="sm" variant="outline" onClick={handleResendVerification} disabled={sendingVerification}>
{sendingVerification ? 'Sende...' : 'Link erneut senden'}
</Button>
{verificationSent && <span className="text-xs text-muted-foreground">Wir haben dir gerade einen neuen Bestätigungslink geschickt.</span>}
</div>
</div>
</Alert>
)}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.key} className="bg-gradient-to-br from-background to-muted/60 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
<div>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
</div>
<span className="rounded-full border bg-background/70 p-2 text-muted-foreground">
<stat.icon className="size-5" />
</span>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>{stat.description}</p>
{stat.key === 'credit-balance' && stat.extra !== null && (
<div className="text-xs text-muted-foreground">{stat.extra} weitere Events im aktuellen Paket enthalten.</div>
)}
{stat.key === 'task-progress' && (
<div className="space-y-1">
<Progress value={taskProgress} />
<span className="text-xs text-muted-foreground">{taskProgress}% deiner Event-Checkliste erledigt.</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Bevorstehende Events</CardTitle>
<p className="text-sm text-muted-foreground">Status, Uploads und Aufgaben deiner nächsten Events im Überblick.</p>
</div>
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>
{upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{upcomingEvents.length === 0 && (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-6 text-center">
<p className="text-sm text-muted-foreground">
Plane dein erstes Event und begleite den gesamten Ablauf vom Briefing bis zur Nachbereitung direkt hier im Dashboard.
</p>
</div>
)}
{upcomingEvents.map((event) => (
<div key={event.id} className="rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold leading-tight">{event.name}</h3>
<p className="text-sm text-muted-foreground">
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} Fotos</span>
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} Aufgaben</span>
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} Links</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Nächstes Paket &amp; Credits</CardTitle>
<p className="text-sm text-muted-foreground">Behalte Laufzeiten und verfügbaren Umfang stets im Blick.</p>
</CardHeader>
<CardContent className="space-y-3">
{tenant?.activePackage ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{tenant.activePackage.name}</span>
<Badge variant="outline">{tenant.activePackage.remainingEvents ?? 0} Events übrig</Badge>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Läuft ab</span>
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>Preis</span>
<span>{renderPrice(tenant.activePackage.price)}</span>
</div>
</div>
{latestPurchase && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.
</div>
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
Noch kein aktives Paket. <Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">Jetzt Paket auswählen</Link> und direkt Events planen.
</div>
)}
<Separator />
<div className="flex items-center justify-between text-sm">
<span>Event Credits insgesamt</span>
<span className="font-medium">{tenant?.eventCreditsBalance ?? 0}</span>
</div>
<div className="text-xs text-muted-foreground">
Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Dein Start in 5 Schritten</CardTitle>
<p className="text-sm text-muted-foreground">Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-3">
{checklistItems.map((item) => (
<div key={item.key} className="flex gap-3 rounded-md border border-border/60 bg-background/50 p-3">
<Checkbox checked={item.done} className="mt-1" readOnly />
<div>
<p className="text-sm font-medium leading-tight">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Aktuelle Buchungen</CardTitle>
<p className="text-sm text-muted-foreground">Verfolge deine gebuchten Pakete und Erweiterungen.</p>
</div>
<Badge variant="outline">{recentPurchases.length} Einträge</Badge>
</CardHeader>
<CardContent>
{recentPurchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-center text-sm text-muted-foreground">
Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead className="hidden sm:table-cell">Typ</TableHead>
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
<TableHead className="hidden md:table-cell">Datum</TableHead>
<TableHead className="text-right">Preis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentPurchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
<TableCell className="text-right font-medium">{renderPrice(purchase.price)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Schnellzugriff</CardTitle>
<p className="text-sm text-muted-foreground">Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.</p>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickActions.map((action) => (
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-4">
<div className="flex items-center gap-3">
<span className="rounded-full bg-muted p-2 text-muted-foreground">
<action.icon className="size-5" />
</span>
<div>
<p className="text-sm font-semibold leading-tight">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm">
<Link href={action.href} prefetch>
Weiter
</Link>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
</AppLayout>
);