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:
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from './i18n';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { ConsentProvider } from './contexts/consent';
|
||||
import CookieBanner from '@/components/consent/CookieBanner';
|
||||
import React from 'react';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
@@ -45,6 +46,7 @@ createInertiaApp({
|
||||
<ConsentProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App {...props} />
|
||||
<CookieBanner />
|
||||
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||
</I18nextProvider>
|
||||
</ConsentProvider>
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
markdown: string;
|
||||
markdown?: string;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export function LegalMarkdown({ markdown }: Props) {
|
||||
const html = React.useMemo(() => {
|
||||
let safe = markdown
|
||||
export function LegalMarkdown({ markdown = '', html }: Props) {
|
||||
const derived = React.useMemo(() => {
|
||||
if (html && html.trim().length > 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const escaped = markdown
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
safe = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
safe = safe.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
safe = safe
|
||||
|
||||
return escaped
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
|
||||
.join('\n');
|
||||
return safe;
|
||||
}, [markdown]);
|
||||
}, [markdown, html]);
|
||||
|
||||
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: derived }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ type ViewState =
|
||||
};
|
||||
|
||||
type LegalDocumentState =
|
||||
| { phase: 'idle'; title: string; body: string }
|
||||
| { phase: 'loading'; title: string; body: string }
|
||||
| { phase: 'ready'; title: string; body: string }
|
||||
| { phase: 'error'; title: string; body: string };
|
||||
| { phase: 'idle'; title: string; markdown: string; html: string }
|
||||
| { phase: 'loading'; title: string; markdown: string; html: string }
|
||||
| { phase: 'ready'; title: string; markdown: string; html: string }
|
||||
| { phase: 'error'; title: string; markdown: string; html: string };
|
||||
|
||||
type NameStatus = 'idle' | 'saved';
|
||||
|
||||
@@ -223,10 +223,10 @@ function LegalView({
|
||||
<CardHeader>
|
||||
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<LegalMarkdown markdown={document.body} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground">
|
||||
<LegalMarkdown markdown={document.markdown} html={document.html} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -418,17 +418,18 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
|
||||
const [state, setState] = React.useState<LegalDocumentState>({
|
||||
phase: 'idle',
|
||||
title: '',
|
||||
body: '',
|
||||
markdown: '',
|
||||
html: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setState({ phase: 'idle', title: '', body: '' });
|
||||
setState({ phase: 'idle', title: '', markdown: '', html: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setState({ phase: 'loading', title: '', body: '' });
|
||||
setState({ phase: 'loading', title: '', markdown: '', html: '' });
|
||||
|
||||
const langParam = encodeURIComponent(locale);
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
|
||||
@@ -443,7 +444,8 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
|
||||
setState({
|
||||
phase: 'ready',
|
||||
title: payload.title ?? '',
|
||||
body: payload.body_markdown ?? '',
|
||||
markdown: payload.body_markdown ?? '',
|
||||
html: payload.body_html ?? '',
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -451,7 +453,7 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
|
||||
return;
|
||||
}
|
||||
console.error('Failed to load legal page', error);
|
||||
setState({ phase: 'error', title: '', body: '' });
|
||||
setState({ phase: 'error', title: '', markdown: '', html: '' });
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
|
||||
@@ -2,20 +2,48 @@ import React from 'react';
|
||||
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
|
||||
import { useLocale } from './LocaleContext';
|
||||
|
||||
export type TranslateFn = (key: string, fallback?: string) => string;
|
||||
type ReplacementValues = Record<string, string | number>;
|
||||
|
||||
export type TranslateFn = {
|
||||
(key: string): string;
|
||||
(key: string, fallback: string): string;
|
||||
(key: string, replacements: ReplacementValues): string;
|
||||
(key: string, replacements: ReplacementValues, fallback: string): string;
|
||||
};
|
||||
|
||||
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
|
||||
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
|
||||
}
|
||||
|
||||
function applyReplacements(value: string, replacements?: ReplacementValues): string {
|
||||
if (!replacements) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.entries(replacements).reduce((acc, [token, replacement]) => {
|
||||
const pattern = new RegExp(`\\{${token}\\}`, 'g');
|
||||
return acc.replace(pattern, String(replacement));
|
||||
}, value);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const t = React.useCallback<TranslateFn>(
|
||||
(key, fallback) => resolveTranslation(locale, key, fallback),
|
||||
[locale],
|
||||
);
|
||||
const t = React.useCallback<TranslateFn>((key: string, arg2?: ReplacementValues | string, arg3?: string) => {
|
||||
let replacements: ReplacementValues | undefined;
|
||||
let fallback: string | undefined;
|
||||
|
||||
if (typeof arg2 === 'string' || arg2 === undefined) {
|
||||
fallback = arg2 ?? arg3;
|
||||
} else {
|
||||
replacements = arg2;
|
||||
fallback = arg3;
|
||||
}
|
||||
|
||||
const raw = resolveTranslation(locale, key, fallback);
|
||||
|
||||
return applyReplacements(raw, replacements);
|
||||
}, [locale]);
|
||||
|
||||
return React.useMemo(() => ({ t, locale }), [t, locale]);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,16 +73,18 @@ export default function HelpArticlePage() {
|
||||
{article.updated_at && (
|
||||
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
|
||||
)}
|
||||
{servedFromCache && (
|
||||
<Badge variant="secondary" className="bg-amber-200/70 text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={basePath}>
|
||||
← {t('help.article.back')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
|
||||
/>
|
||||
{article.related && article.related.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
|
||||
@@ -95,7 +97,7 @@ export default function HelpArticlePage() {
|
||||
asChild
|
||||
>
|
||||
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
|
||||
{rel.slug}
|
||||
{rel.title ?? rel.slug}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function LegalPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [body, setBody] = React.useState('');
|
||||
const [html, setHtml] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!page) {
|
||||
@@ -29,11 +30,13 @@ export default function LegalPage() {
|
||||
const data = await res.json();
|
||||
setTitle(data.title || '');
|
||||
setBody(data.body_markdown || '');
|
||||
setHtml(data.body_html || '');
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error('Failed to load legal page', error);
|
||||
setTitle('');
|
||||
setBody('');
|
||||
setHtml('');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -50,7 +53,7 @@ export default function LegalPage() {
|
||||
|
||||
return (
|
||||
<Page title={title || fallbackTitle}>
|
||||
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
|
||||
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} html={html} />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type HelpArticleSummary = {
|
||||
last_reviewed_at?: string;
|
||||
owner?: string;
|
||||
updated_at?: string;
|
||||
related?: Array<{ slug: string }>;
|
||||
related?: Array<{ slug: string; title?: string }>;
|
||||
};
|
||||
|
||||
export type HelpArticleDetail = HelpArticleSummary & {
|
||||
|
||||
@@ -16,7 +16,7 @@ i18n
|
||||
backend: {
|
||||
loadPath: '/lang/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
ns: ['marketing', 'auth'],
|
||||
ns: ['marketing', 'auth', 'profile', 'common', 'legal'],
|
||||
defaultNS: 'marketing',
|
||||
supportedLngs: ['de', 'en'],
|
||||
detection: {
|
||||
@@ -26,4 +26,4 @@ i18n
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker';
|
||||
import CookieBanner from '@/components/consent/CookieBanner';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import Footer from '@/layouts/app/Footer';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
@@ -165,7 +164,6 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
||||
))}
|
||||
</Head>
|
||||
<MatomoTracker config={analytics?.matomo} />
|
||||
<CookieBanner />
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="sticky top-0 z-40 border-b border-gray-200/60 bg-white/95 backdrop-blur">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-4">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user