im profil kann ein nutzer nun seine daten exportieren. man kann seinen account löschen. nach 2 jahren werden inaktive accounts gelöscht, 1 monat vorher wird eine email geschickt. Hilfetexte und Legal Pages in der Guest PWA korrigiert und vom layout her optimiert (dark mode).

This commit is contained in:
Codex Agent
2025-11-10 19:55:46 +01:00
parent 447a90a742
commit 2587b2049d
37 changed files with 1650 additions and 50 deletions

View File

@@ -1,6 +1,7 @@
import { useMemo, useRef } from 'react';
import { Transition } from '@headlessui/react';
import { Head, Form, Link, usePage } from '@inertiajs/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';
@@ -49,6 +50,19 @@ type ProfilePageProps = {
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[] = [
@@ -59,7 +73,8 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function ProfileIndex() {
const { userData, tenant, purchases, supportedLocales, locale } = usePage<SharedData & ProfilePageProps>().props;
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',
@@ -73,6 +88,44 @@ export default function ProfileIndex() {
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;
@@ -229,6 +282,108 @@ export default function ProfileIndex() {
</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>
);
}