Files
fotospiel-app/resources/js/pages/Profile/Index.tsx

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 (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 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 &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.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 &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>
</>
);
}