530 lines
28 KiB
TypeScript
530 lines
28 KiB
TypeScript
import { useMemo, useRef } from 'react';
|
|
import { Transition } from '@headlessui/react';
|
|
import { Head, Form, Link, router, useForm, usePage } from '@inertiajs/react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react';
|
|
|
|
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';
|
|
|
|
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;
|
|
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;
|
|
}>;
|
|
dataExport: {
|
|
exports: Array<{
|
|
id: number;
|
|
status: string;
|
|
size: number | null;
|
|
created_at: string | null;
|
|
expires_at: string | null;
|
|
download_url: string | null;
|
|
error_message: string | null;
|
|
}>;
|
|
hasPending: boolean;
|
|
nextRequestAt?: string | null;
|
|
};
|
|
};
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{
|
|
title: 'Profil',
|
|
href: '/profile',
|
|
},
|
|
];
|
|
|
|
export default function ProfileIndex() {
|
|
const { userData, tenant, purchases, supportedLocales, locale, dataExport } = usePage<SharedData & ProfilePageProps>().props;
|
|
const { t } = useTranslation('profile');
|
|
|
|
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 byteFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
|
|
maximumFractionDigits: 1,
|
|
}), [locale]);
|
|
|
|
const dataExportInfo = dataExport ?? { exports: [], hasPending: false, nextRequestAt: null };
|
|
const nextRequestDeadline = dataExportInfo.nextRequestAt ? new Date(dataExportInfo.nextRequestAt) : null;
|
|
const canRequestExport = !dataExportInfo.hasPending && (!nextRequestDeadline || nextRequestDeadline <= new Date());
|
|
|
|
const handleExportRequest = () => {
|
|
router.post('/profile/data-exports');
|
|
};
|
|
|
|
const deleteForm = useForm({ confirmation: '' });
|
|
|
|
const handleDeleteSubmit = (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
deleteForm.delete('/profile/account');
|
|
};
|
|
|
|
const formatExportStatus = (status: string) => t(`export.status.${status}`, status);
|
|
|
|
const formatBytes = (input: number | null) => {
|
|
if (!input) {
|
|
return '—';
|
|
}
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let value = input;
|
|
let unitIndex = 0;
|
|
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
|
|
return `${byteFormatter.format(value)} ${units[unitIndex]}`;
|
|
};
|
|
|
|
const registrationDate = useMemo(() => {
|
|
if (!userData.emailVerifiedAt) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return dateFormatter.format(new Date(userData.emailVerifiedAt));
|
|
} catch {
|
|
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 pt-24 lg:pt-28">
|
|
<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 & 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.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. Wähle ein Paket, um ohne Unterbrechung 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>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card className="border border-muted-foreground/30 bg-white/95 dark:bg-gray-950/50">
|
|
<CardHeader>
|
|
<CardTitle>{t('export.title')}</CardTitle>
|
|
<p className="text-sm text-muted-foreground">{t('export.description')}</p>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{dataExportInfo.exports.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t('export.empty')}</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('export.table.status')}</TableHead>
|
|
<TableHead>{t('export.table.created')}</TableHead>
|
|
<TableHead className="hidden md:table-cell">{t('export.table.expires')}</TableHead>
|
|
<TableHead className="hidden lg:table-cell">{t('export.table.size')}</TableHead>
|
|
<TableHead className="text-right">{t('export.table.action')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{dataExportInfo.exports.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="font-medium">{formatExportStatus(item.status)}</TableCell>
|
|
<TableCell>{formatDate(item.created_at)}</TableCell>
|
|
<TableCell className="hidden md:table-cell">{formatDate(item.expires_at)}</TableCell>
|
|
<TableCell className="hidden lg:table-cell">{formatBytes(item.size)}</TableCell>
|
|
<TableCell className="text-right">
|
|
{item.download_url && item.status === 'ready' ? (
|
|
<a
|
|
href={item.download_url}
|
|
className="text-sm font-semibold text-pink-600 transition hover:text-pink-700"
|
|
>
|
|
{t('export.download')}
|
|
</a>
|
|
) : item.status === 'failed' && item.error_message ? (
|
|
<span className="text-xs text-destructive">{t('export.failed_reason', { reason: item.error_message })}</span>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{dataExportInfo.hasPending && (
|
|
<Alert variant="default" className="border-dashed border-muted-foreground/30 bg-muted/20">
|
|
<AlertTitle>{t('export.pending_alert_title')}</AlertTitle>
|
|
<AlertDescription>{t('export.pending_alert_body')}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{!canRequestExport && nextRequestDeadline && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('export.cooldown', {
|
|
date: nextRequestDeadline.toLocaleDateString(locale ?? 'de-DE'),
|
|
})}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter>
|
|
<Button onClick={handleExportRequest} disabled={!canRequestExport}>
|
|
{t('export.button')}
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
<Card className="border border-red-200 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20">
|
|
<CardHeader>
|
|
<CardTitle>{t('delete.title')}</CardTitle>
|
|
<p className="text-sm text-muted-foreground">{t('delete.description')}</p>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('delete.warning_title')}</AlertTitle>
|
|
<AlertDescription>{t('delete.warning_body')}</AlertDescription>
|
|
</Alert>
|
|
|
|
<form onSubmit={handleDeleteSubmit} className="space-y-3">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="confirmation">{t('delete.confirmation_label')}</Label>
|
|
<Input
|
|
id="confirmation"
|
|
name="confirmation"
|
|
value={deleteForm.data.confirmation}
|
|
onChange={(event) => deleteForm.setData('confirmation', event.target.value.toUpperCase())}
|
|
placeholder={t('delete.confirmation_placeholder')}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t('delete.confirmation_hint')}</p>
|
|
<InputError message={deleteForm.errors.confirmation} />
|
|
</div>
|
|
|
|
<Button type="submit" variant="destructive" className="w-full" disabled={deleteForm.processing}>
|
|
{t('delete.button')}
|
|
</Button>
|
|
</form>
|
|
</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 & 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>
|
|
</>
|
|
);
|
|
}
|