login-seiten neu designt, homepage neu designt. "so funktioniert's" ergänzt und Demo-Seite hinzugefügt. Paketansicht in mobile verbessert.
This commit is contained in:
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Tenant-Admin",
|
||||
"badge": "Fotospiel Tenant Admin",
|
||||
"hero_title": "Event-Steuerung, die sich wie Zuhause anfühlt.",
|
||||
"hero_subtitle": "Wechsle mühelos zwischen Mandanten, behalte Live-Uploads im Blick und teile elegante Einladungen – alles in einer ruhigen Oberfläche.",
|
||||
"title": "Event-Admin",
|
||||
"badge": "Fotospiel Event Admin",
|
||||
"hero_tagline": "Kontrolle behalten, entspannt bleiben",
|
||||
"hero_title": "Das Cockpit für dein Fotospiel Event",
|
||||
"hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen – mobil wie auf dem Desktop.",
|
||||
"features": [
|
||||
"Gestalte QR-Einladungen und druckfertige Layouts in wenigen Klicks passend zu eurer Marke.",
|
||||
"Organisiere Aufgaben, Emotionen und Sammlungen für jeden Eventtyp ohne Excel-Chaos.",
|
||||
"Bleib am Eventtag souverän mit Dashboards, Live-Statistiken und sofortiger Moderation."
|
||||
"Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.",
|
||||
"Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.",
|
||||
"Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus."
|
||||
],
|
||||
"lead": "Die Anmeldung erfolgt über unseren sicheren OAuth-Login und bringt dich direkt wieder zurück.",
|
||||
"panel_copy": "Melde dich mit deinen Fotospiel-Admin-Zugangsdaten an. Wir schützen dein Konto mit OAuth 2.1 und mandantenbewussten Berechtigungen.",
|
||||
"lead": "Du meldest dich über unseren sicheren OAuth-Login an und landest direkt im Event-Dashboard.",
|
||||
"panel_title": "Melde dich an",
|
||||
"panel_copy": "Logge dich mit deinem Fotospiel-Adminzugang ein. Wir schützen dein Konto mit OAuth 2.1 und klaren Rollenrechten.",
|
||||
"cta": "Mit Fotospiel-Login fortfahren",
|
||||
"loading": "Bitte warten …",
|
||||
"oauth_error_title": "Login aktuell nicht möglich",
|
||||
"oauth_error": "Anmeldung fehlgeschlagen: {{message}}",
|
||||
"support": "Du brauchst Zugriff? Wende dich an den Tenant-Inhaber oder schreibe an support@fotospiel.de – wir helfen gern weiter.",
|
||||
"support": "Du brauchst Zugriff? Kontaktiere dein Event-Team oder schreib uns an support@fotospiel.de.",
|
||||
"appearance_label": "Darstellung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"login": {
|
||||
"title": "Tenant Admin",
|
||||
"badge": "Fotospiel Tenant Admin",
|
||||
"hero_title": "Event control that feels at home.",
|
||||
"hero_subtitle": "Switch between tenants, monitor live uploads, and share beautiful invites — all in one calm workspace.",
|
||||
"title": "Event Admin",
|
||||
"badge": "Fotospiel Event Admin",
|
||||
"hero_tagline": "Stay in control, stay relaxed",
|
||||
"hero_title": "Your cockpit for every Fotospiel event",
|
||||
"hero_subtitle": "Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.",
|
||||
"features": [
|
||||
"Design QR invites and print-ready layouts that match your brand in minutes.",
|
||||
"Coordinate tasks, emotions, and achievements for every event flow.",
|
||||
"Stay confident on event day with dashboards, live stats, and instant moderation."
|
||||
"Monitor uploads in real time and archive highlights effortlessly.",
|
||||
"Create invites with personalized QR codes and share them instantly.",
|
||||
"Run tasks, emotions, and slideshows right from the event dashboard."
|
||||
],
|
||||
"lead": "You will be redirected to our secure OAuth login and come right back afterwards.",
|
||||
"panel_copy": "Sign in with your Fotospiel admin credentials to continue. We secure your account with OAuth 2.1 and tenant-aware permissions.",
|
||||
"lead": "Use our secure OAuth login and land directly in the event dashboard.",
|
||||
"panel_title": "Sign in",
|
||||
"panel_copy": "Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.",
|
||||
"cta": "Continue with Fotospiel login",
|
||||
"loading": "Signing you in …",
|
||||
"oauth_error_title": "Login not possible right now",
|
||||
"oauth_error": "Sign-in failed: {{message}}",
|
||||
"support": "Need access? Contact your tenant owner or email support@fotospiel.de — we're happy to help.",
|
||||
"support": "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.",
|
||||
"appearance_label": "Appearance"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '../constants';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
|
||||
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
|
||||
import {
|
||||
CANVAS_HEIGHT,
|
||||
@@ -252,6 +253,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
|
||||
const event = state.event;
|
||||
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
|
||||
const eventDate = event?.event_date ?? null;
|
||||
|
||||
const selectedInvite = React.useMemo(
|
||||
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
|
||||
@@ -587,7 +589,13 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = `${selectedInvite.token || 'invite'}-qr.png`;
|
||||
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
||||
const downloadName = buildDownloadFilename(
|
||||
['QR Code fuer', eventName, eventDateSegment],
|
||||
'png',
|
||||
'qr-code',
|
||||
);
|
||||
link.download = downloadName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -596,7 +604,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
console.error('[Invites] QR download failed', error);
|
||||
setExportError(t('invites.export.qr.error', 'QR-Code konnte nicht gespeichert werden.'));
|
||||
}
|
||||
}, [selectedInvite, t]);
|
||||
}, [selectedInvite, eventName, eventDate, t]);
|
||||
|
||||
const handleExportDownload = React.useCallback(
|
||||
async (format: string) => {
|
||||
@@ -609,6 +617,13 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
setExportDownloadBusy(busyKey);
|
||||
setExportError(null);
|
||||
|
||||
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
||||
const filename = buildDownloadFilename(
|
||||
['Einladungslayout', eventName, exportLayout.name ?? null, eventDateSegment],
|
||||
normalizedFormat,
|
||||
'einladungslayout',
|
||||
);
|
||||
|
||||
const exportOptions = {
|
||||
elements: exportElements,
|
||||
accentColor: exportPreview.accentColor,
|
||||
@@ -623,19 +638,17 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
selectedId: null,
|
||||
} as const;
|
||||
|
||||
const filenameStem = `${selectedInvite.token || 'invite'}-${exportLayout.id}`;
|
||||
|
||||
try {
|
||||
if (normalizedFormat === 'png') {
|
||||
const dataUrl = await generatePngDataUrl(exportOptions);
|
||||
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
|
||||
await triggerDownloadFromDataUrl(dataUrl, filename);
|
||||
} else if (normalizedFormat === 'pdf') {
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
'a4',
|
||||
'portrait',
|
||||
);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes as any], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
|
||||
} else {
|
||||
setExportError(t('invites.customizer.errors.downloadFailed', 'Download fehlgeschlagen. Bitte versuche es erneut.'));
|
||||
}
|
||||
@@ -646,7 +659,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
setExportDownloadBusy(null);
|
||||
}
|
||||
},
|
||||
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, t]
|
||||
[selectedInvite, exportLayout, exportPreview, exportElements, exportQr, exportLogo, eventName, eventDate, t]
|
||||
);
|
||||
|
||||
const handleExportPrint = React.useCallback(
|
||||
@@ -809,6 +822,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
<InviteLayoutCustomizerPanel
|
||||
invite={selectedInvite ?? null}
|
||||
eventName={eventName}
|
||||
eventDate={eventDate}
|
||||
saving={customizerSaving}
|
||||
resetting={customizerResetting}
|
||||
onSave={handleSaveCustomization}
|
||||
|
||||
@@ -53,108 +53,149 @@ export default function LoginPage(): JSX.Element {
|
||||
}));
|
||||
}, [t]);
|
||||
|
||||
const heroTagline = t('login.hero_tagline', 'Stay in control, stay relaxed');
|
||||
const heroTitle = t('login.hero_title', 'Your cockpit for every Fotospiel event');
|
||||
const heroSubtitle = t('login.hero_subtitle', 'Moderation, uploads, and communication come together in one calm workspace — on desktop and mobile.');
|
||||
const panelTitle = t('login.panel_title', t('login.title', 'Event Admin'));
|
||||
const leadCopy = t('login.lead', 'Use our secure OAuth login and land directly in the event dashboard.');
|
||||
const panelCopy = t('login.panel_copy', 'Sign in with your Fotospiel admin access. OAuth 2.1 and clear role permissions keep your account protected.');
|
||||
const supportCopy = t('login.support', "Need access? Contact your event team or email support@fotospiel.de — we're happy to help.");
|
||||
|
||||
const isLoading = status === 'loading';
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[var(--brand-navy)] text-white">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,var(--brand-rose-soft)_0%,rgba(3,7,18,0.65)_55%,rgba(15,76,117,0.9)_100%)] opacity-95" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-[-25%] w-[55%] bg-[radial-gradient(circle_at_center,var(--brand-sky)_0%,rgba(255,255,255,0)_70%)] opacity-40" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-[-20%] w-[45%] bg-[radial-gradient(circle_at_center,var(--brand-rose)_0%,rgba(255,255,255,0)_65%)] opacity-35" />
|
||||
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
|
||||
|
||||
<div className="relative z-10 flex min-h-screen flex-col">
|
||||
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 pt-10">
|
||||
<div className="relative z-10 flex min-h-svh flex-col">
|
||||
<header className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 pt-10 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/15 shadow-lg shadow-black/10 backdrop-blur">
|
||||
<AppLogoIcon className="h-7 w-7 text-white" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge')}</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-white/70">{t('login.badge', 'Fotospiel Event Admin')}</p>
|
||||
<p className="text-lg font-semibold">Fotospiel</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppearanceToggleDropdown />
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-6 pb-16 pt-12">
|
||||
<div className="grid flex-1 gap-12 lg:grid-cols-[0.95fr_1.05fr]" data-testid="tenant-login-layout">
|
||||
<section className="order-2 space-y-10 lg:order-1">
|
||||
<div className="space-y-5">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-1 text-sm font-medium text-white/80 backdrop-blur">
|
||||
<Sparkles className="h-4 w-4 text-[var(--brand-gold)]" />
|
||||
{t('login.badge')}
|
||||
</span>
|
||||
<h1 className="text-4xl font-semibold leading-tight sm:text-5xl">
|
||||
{t('login.hero_title')}
|
||||
</h1>
|
||||
<p className="max-w-xl text-base text-white/80 sm:text-lg">
|
||||
{t('login.hero_subtitle')}
|
||||
</p>
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col px-4 pb-16 pt-12 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 space-y-5 text-center md:hidden">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden />
|
||||
{heroTagline}
|
||||
</span>
|
||||
<h1 className="text-3xl font-semibold leading-tight sm:text-4xl">{heroTitle}</h1>
|
||||
<p className="mx-auto max-w-xl text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 gap-10 md:grid-cols-[1.08fr_1fr]" data-testid="tenant-login-layout">
|
||||
<section className="relative hidden h-full flex-col justify-between gap-10 overflow-hidden rounded-3xl border border-white/15 bg-gradient-to-br from-[#1d1937] via-[#2a1134] to-[#ff5f87] p-10 md:flex">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 opacity-40">
|
||||
<div className="absolute -inset-16 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.5),_transparent_55%),radial-gradient(circle_at_bottom_left,_rgba(236,72,153,0.35),_transparent_60%)]" />
|
||||
</div>
|
||||
|
||||
{featureList.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{featureList.map(({ text, Icon }, index) => (
|
||||
<div
|
||||
key={`login-feature-${index}`}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/15 bg-white/10 p-5 shadow-lg shadow-black/5 backdrop-blur transition hover:border-white/35"
|
||||
>
|
||||
<Icon className="mb-3 h-5 w-5 text-[var(--brand-gold)] transition group-hover:text-white" />
|
||||
<p className="text-sm text-white/90">{text}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="relative z-10 flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3 text-xs font-semibold uppercase tracking-[0.45em] text-white/70">
|
||||
<Sparkles className="h-4 w-4" aria-hidden />
|
||||
<span className="font-sans-marketing">{heroTagline}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="flex items-center gap-2 text-sm text-white/70">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
{t('login.lead')}
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-display text-3xl leading-tight sm:text-4xl">{heroTitle}</h2>
|
||||
<p className="text-sm text-white/80 sm:text-base">{heroSubtitle}</p>
|
||||
</div>
|
||||
|
||||
{featureList.length ? (
|
||||
<ul className="space-y-4">
|
||||
{featureList.map(({ text, Icon }, index) => (
|
||||
<li key={`login-feature-desktop-${index}`} className="flex items-start gap-3">
|
||||
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15 backdrop-blur">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<span className="space-y-1">
|
||||
<p className="text-sm font-semibold tracking-tight sm:text-base">{text}</p>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="relative z-10 flex items-center gap-2 text-xs font-medium text-white/75">
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
{leadCopy}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="order-1 lg:order-2">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 -translate-y-4 translate-x-6 scale-95 rounded-3xl bg-white/20 opacity-50 blur-2xl" />
|
||||
<div className="relative overflow-hidden rounded-3xl border border-white/20 bg-white/90 p-10 text-slate-900 shadow-2xl shadow-black/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/90 dark:text-slate-50">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold">{t('login.title')}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">{t('login.panel_copy')}</p>
|
||||
</div>
|
||||
|
||||
{oauthError ? (
|
||||
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
|
||||
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
|
||||
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--brand-rose)] via-[var(--brand-gold)] to-[var(--brand-sky)] px-8 py-3 text-base font-semibold text-slate-900 shadow-lg shadow-rose-400/30 transition hover:opacity-90 focus-visible:ring-4 focus-visible:ring-brand-rose/40 dark:text-slate-900"
|
||||
disabled={isLoading}
|
||||
onClick={() => login(redirectTarget)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('login.cta')}
|
||||
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-300">
|
||||
{t('login.support')}
|
||||
</p>
|
||||
<section className="relative">
|
||||
<div className="absolute inset-0 -translate-y-4 translate-x-4 scale-95 rounded-3xl bg-white/20 opacity-45 blur-2xl" aria-hidden />
|
||||
<div className="relative flex h-full flex-col justify-between gap-6 overflow-hidden rounded-3xl border border-white/15 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/20 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
|
||||
<div className="space-y-3">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200/50 bg-rose-50/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500/80 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200/80">
|
||||
{t('login.badge', 'Fotospiel Event Admin')}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold">{panelTitle}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">{panelCopy}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauthError ? (
|
||||
<Alert className="border-red-400/40 bg-red-50/80 text-red-800 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100">
|
||||
<AlertTitle>{t('login.oauth_error_title')}</AlertTitle>
|
||||
<AlertDescription>{t('login.oauth_error', { message: oauthError })}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="group flex w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] focus-visible:ring-4 focus-visible:ring-rose-400/40"
|
||||
disabled={isLoading}
|
||||
onClick={() => login(redirectTarget)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('login.loading', 'Signing you in …')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('login.cta', 'Continue with Fotospiel login')}
|
||||
<ArrowRight className="h-5 w-5 transition group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2 text-xs leading-relaxed text-slate-500 dark:text-slate-300">
|
||||
<p>{leadCopy}</p>
|
||||
<p>{supportCopy}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{featureList.length ? (
|
||||
<div className="mt-10 grid gap-4 md:hidden">
|
||||
{featureList.map(({ text, Icon }, index) => (
|
||||
<div
|
||||
key={`login-feature-mobile-${index}`}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/15 bg-white/10 p-4 text-sm text-white/85 shadow-lg shadow-black/15 backdrop-blur"
|
||||
>
|
||||
<span className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/15">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
triggerDownloadFromBlob,
|
||||
triggerDownloadFromDataUrl,
|
||||
} from './invite-layout/export-utils';
|
||||
import { buildDownloadFilename, normalizeEventDateSegment } from './invite-layout/fileNames';
|
||||
|
||||
export type { QrLayoutCustomization } from './invite-layout/schema';
|
||||
|
||||
@@ -182,6 +183,7 @@ function serializeElements(elements: LayoutElement[], context: LayoutSerializati
|
||||
type InviteLayoutCustomizerPanelProps = {
|
||||
invite: EventQrInvite | null;
|
||||
eventName: string;
|
||||
eventDate: string | null;
|
||||
saving: boolean;
|
||||
resetting: boolean;
|
||||
onSave: (customization: QrLayoutCustomization) => Promise<void>;
|
||||
@@ -199,6 +201,7 @@ const ZOOM_STEP = 0.05;
|
||||
export function InviteLayoutCustomizerPanel({
|
||||
invite,
|
||||
eventName,
|
||||
eventDate,
|
||||
saving,
|
||||
resetting,
|
||||
onSave,
|
||||
@@ -1391,7 +1394,12 @@ export function InviteLayoutCustomizerPanel({
|
||||
}
|
||||
|
||||
const normalizedFormat = format.toLowerCase();
|
||||
const filenameStem = `${invite.token || 'invite'}-${normalizedFormat}`;
|
||||
const eventDateSegment = normalizeEventDateSegment(eventDate);
|
||||
const filename = buildDownloadFilename(
|
||||
['Einladungslayout', eventName, activeLayout?.name ?? null, eventDateSegment],
|
||||
normalizedFormat,
|
||||
'einladungslayout',
|
||||
);
|
||||
setDownloadBusy(normalizedFormat);
|
||||
setError(null);
|
||||
|
||||
@@ -1412,14 +1420,14 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
if (normalizedFormat === 'png') {
|
||||
const dataUrl = await generatePngDataUrl(exportOptions);
|
||||
await triggerDownloadFromDataUrl(dataUrl, `${filenameStem}.png`);
|
||||
await triggerDownloadFromDataUrl(dataUrl, filename);
|
||||
} else if (normalizedFormat === 'pdf') {
|
||||
const pdfBytes = await generatePdfBytes(
|
||||
exportOptions,
|
||||
'a4',
|
||||
'portrait',
|
||||
);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), `${filenameStem}.pdf`);
|
||||
triggerDownloadFromBlob(new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' }), filename);
|
||||
} else {
|
||||
throw new Error(`Unsupported format: ${normalizedFormat}`);
|
||||
}
|
||||
@@ -1509,34 +1517,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('invites.customizer.copy', 'Bearbeite Texte, Farben und Positionen direkt neben der Live-Vorschau. Änderungen werden sofort sichtbar.')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
|
||||
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (formRef.current) {
|
||||
if (typeof formRef.current.requestSubmit === 'function') {
|
||||
formRef.current.requestSubmit();
|
||||
} else {
|
||||
formRef.current.submit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/20"
|
||||
disabled={saving || resetting}
|
||||
>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{t('invites.customizer.actions.save', 'Layout speichern')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
|
||||
{renderActionButtons('inline')}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
@@ -1845,7 +1827,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end', showFloatingActions ? 'hidden' : 'flex')}>
|
||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
|
||||
{renderActionButtons('inline')}
|
||||
</div>
|
||||
<div ref={actionsSentinelRef} className="h-1 w-full" />
|
||||
|
||||
@@ -239,7 +239,7 @@ export function DesignerCanvas({
|
||||
const elementId = target.elementId;
|
||||
|
||||
const bounds = target.getBoundingRect();
|
||||
let nextPatch: Partial<LayoutElement> = {
|
||||
const nextPatch: Partial<LayoutElement> = {
|
||||
x: clamp(Math.round(bounds.left ?? 0), 20, CANVAS_WIDTH - 20),
|
||||
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export function sanitizeFilenameSegment(value: string | null | undefined, fallback = ''): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.trim()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
|
||||
const slug = normalized.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
||||
|
||||
return slug.length ? slug : fallback;
|
||||
}
|
||||
|
||||
export function normalizeEventDateSegment(dateValue: string | null | undefined): string | null {
|
||||
if (!dateValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = dateValue.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isoCandidate = trimmed.slice(0, 10);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(isoCandidate)) {
|
||||
return isoCandidate;
|
||||
}
|
||||
|
||||
const parsed = new Date(trimmed);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function buildDownloadFilename(
|
||||
parts: Array<string | null | undefined>,
|
||||
extension: string,
|
||||
fallback = 'download',
|
||||
): string {
|
||||
const sanitizedParts = parts
|
||||
.map((part) => sanitizeFilenameSegment(part, ''))
|
||||
.filter((segment) => segment.length > 0);
|
||||
|
||||
const base = sanitizedParts.length ? sanitizedParts.join('-') : fallback;
|
||||
const cleanExtension = sanitizeFilenameSegment(extension, '').replace(/[^a-z0-9]/gi, '') || 'bin';
|
||||
|
||||
return `${base}.${cleanExtension.toLowerCase()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user