Hintergründe zum EventInvitePage Layout Customizer hinzugefügt. Badge und CTA entfernt, Textfelder zu Textareas gemacht. Geschenkgutscheine verbessert, E-Mail-Versand ergänzt + Resend + Confirmationseite mit Code-Copy und Link zur Package-Seite, die den Code als URL-Parameter enthält.

This commit is contained in:
Codex Agent
2025-12-08 16:20:04 +01:00
parent 046e2fe3ec
commit 4784c23e70
35 changed files with 1503 additions and 136 deletions

View File

@@ -54,6 +54,7 @@ import {
triggerDownloadFromBlob,
triggerDownloadFromDataUrl,
} from './components/invite-layout/export-utils';
import { preloadedBackgrounds } from './components/invite-layout/backgrounds';
import { useOnboardingProgress } from '../onboarding';
import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar';
@@ -358,6 +359,7 @@ export default function EventInvitesPage(): React.ReactElement {
const secondaryColor = '#1F2937';
const badgeColor = normalizeHexColor(customization?.badge_color ?? (layoutPreview.accent as string | undefined)) ?? accentColor;
const gradient = normalizeGradient(customization?.background_gradient ?? layoutPreview.background_gradient ?? null);
const backgroundImage = customization?.background_image ?? null;
const instructions = ensureInstructionList(customization?.instructions, exportLayout.instructions ?? []);
const workflowSteps = toStringList(t('invites.export.workflow.steps', { returnObjects: true }));
@@ -373,6 +375,7 @@ export default function EventInvitesPage(): React.ReactElement {
backgroundStyle: buildBackgroundStyle(backgroundColor, gradient),
backgroundColor,
backgroundGradient: gradient,
backgroundImage,
badgeLabel: customization?.badge_label?.trim() || t('tasks.customizer.defaults.badgeLabel'),
badgeColor,
badgeTextColor: '#FFFFFF',
@@ -722,6 +725,7 @@ export default function EventInvitesPage(): React.ReactElement {
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
backgroundImageUrl: exportPreview.backgroundImage ?? null,
readOnly: true,
selectedId: null,
} as const;
@@ -769,6 +773,7 @@ export default function EventInvitesPage(): React.ReactElement {
logoDataUrl: exportLogo,
backgroundColor: exportPreview.backgroundColor ?? '#FFFFFF',
backgroundGradient: exportPreview.backgroundGradient ?? null,
backgroundImageUrl: exportPreview.backgroundImage ?? null,
readOnly: true,
selectedId: null,
} as const;
@@ -1008,6 +1013,7 @@ export default function EventInvitesPage(): React.ReactElement {
initialCustomization={currentCustomization}
draftCustomization={customizerDraft}
onDraftChange={handleCustomizerDraftChange}
backgroundImages={preloadedBackgrounds}
/>
)}
</section>
@@ -1102,6 +1108,7 @@ export default function EventInvitesPage(): React.ReactElement {
onChange={handlePreviewChange}
background={exportPreview.backgroundColor}
gradient={exportPreview.backgroundGradient}
backgroundImageUrl={exportPreview.backgroundImage ?? null}
accent={exportPreview.accentColor}
text={exportPreview.textColor}
secondary={exportPreview.secondaryColor}

View File

@@ -34,6 +34,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { ensureFontLoaded, useTenantFonts } from '../../lib/fonts';
import { preloadedBackgrounds, type BackgroundImageOption } from './invite-layout/backgrounds';
const DEFAULT_FONT_VALUE = '__default';
@@ -115,6 +116,12 @@ function sanitizePayload(payload: QrLayoutCustomization): QrLayoutCustomization
normalized.background_color = sanitizeColor(payload.background_color ?? null) ?? undefined;
normalized.secondary_color = sanitizeColor(payload.secondary_color ?? null) ?? undefined;
normalized.badge_color = sanitizeColor(payload.badge_color ?? null) ?? undefined;
if (typeof payload.background_image === 'string') {
const trimmed = payload.background_image.trim();
normalized.background_image = trimmed.length ? trimmed : undefined;
} else {
normalized.background_image = undefined;
}
if (payload.background_gradient && typeof payload.background_gradient === 'object') {
const { angle, stops } = payload.background_gradient as { angle?: number; stops?: unknown };
@@ -192,6 +199,7 @@ type InviteLayoutCustomizerPanelProps = {
invite: EventQrInvite | null;
eventName: string;
eventDate: string | null;
backgroundImages?: BackgroundImageOption[];
saving: boolean;
resetting: boolean;
onSave: (customization: QrLayoutCustomization) => Promise<void>;
@@ -217,6 +225,7 @@ export function InviteLayoutCustomizerPanel({
initialCustomization,
draftCustomization,
onDraftChange,
backgroundImages = preloadedBackgrounds,
}: InviteLayoutCustomizerPanelProps): React.JSX.Element {
const { t } = useTranslation('management');
const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts();
@@ -792,6 +801,7 @@ export function InviteLayoutCustomizerPanel({
background_color: sanitizeColor((reuseCustomization ? activeCustomization?.background_color : activeLayout.preview?.background) ?? null) ?? '#FFFFFF',
secondary_color: reuseCustomization ? activeCustomization?.secondary_color ?? '#1F2937' : '#1F2937',
badge_color: reuseCustomization ? activeCustomization?.badge_color ?? '#2563EB' : '#2563EB',
background_image: reuseCustomization ? activeCustomization?.background_image ?? null : null,
background_gradient: reuseCustomization ? activeCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null : activeLayout.preview?.background_gradient ?? null,
logo_data_url: reuseCustomization ? activeCustomization?.logo_data_url ?? activeCustomization?.logo_url ?? null : null,
mode: reuseCustomization ? activeCustomization?.mode : 'standard',
@@ -1285,9 +1295,10 @@ export function InviteLayoutCustomizerPanel({
blocks.push(
<div className="space-y-2" key={`${element.id}-binding`}>
<Label>{binding.label}</Label>
<Input
<Textarea
value={value}
onChange={(event) => updateForm(binding.field, event.target.value as never)}
className="min-h-[72px]"
/>
</div>
);
@@ -1551,6 +1562,7 @@ export function InviteLayoutCustomizerPanel({
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF',
backgroundGradient: form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null,
backgroundImageUrl: form.background_image ?? null,
readOnly: true,
selectedId: null,
} as const;
@@ -1595,6 +1607,7 @@ export function InviteLayoutCustomizerPanel({
logoDataUrl: form.logo_data_url ?? form.logo_url ?? null,
backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#FFFFFF',
backgroundGradient: form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null,
backgroundImageUrl: form.background_image ?? null,
readOnly: true,
selectedId: null,
} as const;
@@ -1738,18 +1751,20 @@ export function InviteLayoutCustomizerPanel({
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="invite-headline">{t('invites.customizer.fields.headline', 'Überschrift')}</Label>
<Input
<Textarea
id="invite-headline"
value={form.headline ?? ''}
onChange={(event) => updateForm('headline', event.target.value)}
className="min-h-[68px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-subtitle">{t('invites.customizer.fields.subtitle', 'Unterzeile')}</Label>
<Input
<Textarea
id="invite-subtitle"
value={form.subtitle ?? ''}
onChange={(event) => updateForm('subtitle', event.target.value)}
className="min-h-[68px]"
/>
</div>
<div className="space-y-2">
@@ -1761,39 +1776,23 @@ export function InviteLayoutCustomizerPanel({
className="min-h-[96px]"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="invite-badge">{t('invites.customizer.fields.badge', 'Badge-Label')}</Label>
<Input
id="invite-badge"
value={form.badge_label ?? ''}
onChange={(event) => updateForm('badge_label', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-cta">{t('invites.customizer.fields.cta', 'Call-to-Action')}</Label>
<Input
id="invite-cta"
value={form.cta_label ?? ''}
onChange={(event) => updateForm('cta_label', event.target.value)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="invite-link-heading">{t('invites.customizer.fields.linkHeading', 'Link-Überschrift')}</Label>
<Input
<Textarea
id="invite-link-heading"
value={form.link_heading ?? ''}
onChange={(event) => updateForm('link_heading', event.target.value)}
className="min-h-[68px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-link-label">{t('invites.customizer.fields.linkLabel', 'Link/Begleittext')}</Label>
<Input
<Textarea
id="invite-link-label"
value={form.link_label ?? ''}
onChange={(event) => updateForm('link_label', event.target.value)}
className="min-h-[68px]"
/>
</div>
</div>
@@ -1832,18 +1831,56 @@ export function InviteLayoutCustomizerPanel({
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-badge-color">{t('invites.customizer.fields.badgeColor', 'Badge')}</Label>
<Input
id="invite-badge-color"
type="color"
value={form.badge_color ?? '#2563EB'}
onChange={(event) => updateForm('badge_color', event.target.value)}
className="h-11"
/>
</div>
</div>
{backgroundImages.length ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('invites.customizer.fields.backgroundImage', 'Hintergrundbild')}</Label>
{form.background_image ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
updateForm('background_image', null as never);
updateForm('background_gradient', null as never);
}}
>
{t('invites.customizer.actions.removeBackgroundImage', 'Bild entfernen')}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{t('invites.customizer.fields.backgroundImageHint', 'Wähle ein Bild. Es ersetzt den Farbverlauf und füllt den ganzen Hintergrund.')}
</p>
<div className="grid gap-3 sm:grid-cols-3">
{backgroundImages.map((item) => {
const isActive = form.background_image === item.url;
return (
<button
key={item.id}
type="button"
onClick={() => {
updateForm('background_image', item.url as never);
updateForm('background_gradient', null as never);
}}
className={cn(
'group overflow-hidden rounded-lg border text-left shadow-sm transition focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
isActive ? 'border-primary ring-2 ring-primary/50' : 'border-[var(--tenant-border-strong)]'
)}
>
<div className="aspect-[3/4] w-full overflow-hidden bg-[var(--tenant-surface-muted)]">
<img src={item.url} alt={item.label} className="h-full w-full object-cover transition group-hover:scale-105" />
</div>
<div className="p-2 text-xs text-muted-foreground line-clamp-1">{item.label}</div>
</button>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
<Label>{t('invites.customizer.fields.logo', 'Logo')}</Label>
{form.logo_data_url ? (
@@ -2075,6 +2112,7 @@ export function InviteLayoutCustomizerPanel({
onChange={updateElement}
background={form.background_color ?? activeLayout.preview?.background ?? '#FFFFFF'}
gradient={form.background_gradient ?? activeLayout.preview?.background_gradient ?? null}
backgroundImageUrl={form.background_image ?? null}
accent={form.accent_color ?? activeLayout.preview?.accent ?? '#6366F1'}
text={form.text_color ?? activeLayout.preview?.text ?? '#111827'}
secondary={form.secondary_color ?? '#1F2937'}

View File

@@ -22,6 +22,7 @@ type DesignerCanvasProps = {
badge: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
backgroundImageUrl?: string | null;
scale?: number;
readOnly?: boolean;
layoutKey?: string;
@@ -36,6 +37,7 @@ export function DesignerCanvas({
onChange,
background,
gradient,
backgroundImageUrl = null,
accent,
text,
secondary,
@@ -233,6 +235,8 @@ export function DesignerCanvas({
return;
}
const elementId = target.elementId;
const action = event.transform?.action ?? null;
const isScalingAction = action?.startsWith('scale') || action === 'resize';
const bounds = target.getBoundingRect();
const nextPatch: Partial<LayoutElement> = {
@@ -240,61 +244,55 @@ export function DesignerCanvas({
y: clamp(Math.round(bounds.top ?? 0), 20, CANVAS_HEIGHT - 20),
};
// Manual collision check: Calculate overlap and push vertically
const otherObjects = canvas
.getObjects()
.filter((obj): obj is FabricObjectWithId => obj !== target && Boolean((obj as FabricObjectWithId).elementId));
otherObjects.forEach((other) => {
const otherBounds = other.getBoundingRect();
const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left));
const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top));
if (overlapX > 0 && overlapY > 0) {
// Push down by 120px if overlap (massive spacing für größeren QR-Code)
nextPatch.y = Math.max(nextPatch.y ?? 0, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
}
});
const isImage = target.type === 'image';
if (isImage) {
const currentScaleX = target.scaleX ?? 1;
const currentScaleY = target.scaleY ?? 1;
const naturalWidth = target.width ?? 0;
const naturalHeight = target.height ?? 0;
if (elementId === 'qr') {
// For QR: Enforce uniform scale, cap size, padding=0
const avgScale = (currentScaleX + currentScaleY) / 2;
const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR
nextPatch.width = cappedSize;
nextPatch.height = cappedSize;
nextPatch.scaleX = cappedSize / naturalWidth;
nextPatch.scaleY = cappedSize / naturalHeight;
if (isScalingAction) {
if (isImage) {
const currentScaleX = target.scaleX ?? 1;
const currentScaleY = target.scaleY ?? 1;
const naturalWidth = target.width ?? 0;
const naturalHeight = target.height ?? 0;
if (elementId === 'qr') {
// For QR: Enforce uniform scale, cap size, padding=0
const avgScale = (currentScaleX + currentScaleY) / 2;
const cappedSize = Math.min(Math.round(naturalWidth * avgScale), 800); // Cap at 800px for massive QR
nextPatch.width = cappedSize;
nextPatch.height = cappedSize;
nextPatch.scaleX = cappedSize / naturalWidth;
nextPatch.scaleY = cappedSize / naturalHeight;
target.set({
left: nextPatch.x,
top: nextPatch.y,
scaleX: nextPatch.scaleX,
scaleY: nextPatch.scaleY,
padding: 12, // Increased padding for better frame visibility
uniformScaling: true, // Lock aspect ratio
lockScalingFlip: true,
});
} else {
nextPatch.width = Math.round(naturalWidth * currentScaleX);
nextPatch.height = Math.round(naturalHeight * currentScaleY);
nextPatch.scaleX = currentScaleX;
nextPatch.scaleY = currentScaleY;
target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 });
}
} else {
nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40);
nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40);
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
scaleX: nextPatch.scaleX,
scaleY: nextPatch.scaleY,
padding: 12, // Increased padding for better frame visibility
uniformScaling: true, // Lock aspect ratio
lockScalingFlip: true,
width: nextPatch.width,
height: nextPatch.height,
padding: 10, // Default padding for text
});
} else {
nextPatch.width = Math.round(naturalWidth * currentScaleX);
nextPatch.height = Math.round(naturalHeight * currentScaleY);
nextPatch.scaleX = currentScaleX;
nextPatch.scaleY = currentScaleY;
target.set({ left: nextPatch.x, top: nextPatch.y, padding: 10 });
}
} else {
nextPatch.width = clamp(Math.round(bounds.width ?? target.width ?? 0), 40, CANVAS_WIDTH - 40);
nextPatch.height = clamp(Math.round(bounds.height ?? target.height ?? 0), 40, CANVAS_HEIGHT - 40);
// Dragging: keep size, only move
target.set({
scaleX: 1,
scaleY: 1,
left: nextPatch.x,
top: nextPatch.y,
width: nextPatch.width,
height: nextPatch.height,
padding: 10, // Default padding for text
});
}
@@ -349,6 +347,7 @@ export function DesignerCanvas({
logoDataUrl,
background,
gradient,
backgroundImageUrl,
readOnly,
});
@@ -367,6 +366,7 @@ export function DesignerCanvas({
logoDataUrl,
backgroundColor: background,
backgroundGradient: gradient,
backgroundImageUrl,
readOnly,
}).catch((error) => {
console.error('[Fabric] Failed to render layout', error);
@@ -381,6 +381,7 @@ export function DesignerCanvas({
logoDataUrl,
background,
gradient,
backgroundImageUrl,
readOnly,
]);
@@ -456,6 +457,7 @@ export type FabricRenderOptions = {
logoDataUrl: string | null;
backgroundColor: string;
backgroundGradient: { angle?: number; stops?: string[] } | null;
backgroundImageUrl?: string | null;
readOnly: boolean;
};
@@ -473,13 +475,21 @@ export async function renderFabricLayout(
logoDataUrl,
backgroundColor,
backgroundGradient,
backgroundImageUrl,
readOnly,
} = options;
canvas.discardActiveObject();
// Aggressively clear previous objects/state to avoid stacking duplicates between renders.
try {
const existing = canvas.getObjects();
existing.forEach((obj) => canvas.remove(obj));
} catch (error) {
console.warn('[Invites][Fabric] failed to remove existing objects', error);
}
canvas.clear();
applyBackground(canvas, backgroundColor, backgroundGradient);
await applyBackground(canvas, backgroundColor, backgroundGradient, backgroundImageUrl);
console.debug('[Invites][Fabric] render', {
elementCount: elements.length,
@@ -543,11 +553,70 @@ export async function renderFabricLayout(
canvas.renderAll();
}
export function applyBackground(
export async function applyBackground(
canvas: fabric.Canvas,
color: string,
gradient: { angle?: number; stops?: string[] } | null,
): void {
backgroundImageUrl?: string | null,
): Promise<void> {
try {
if (typeof canvas.setBackgroundImage === 'function') {
canvas.setBackgroundImage(null, canvas.requestRenderAll.bind(canvas));
} else {
// Fallback for environments where setBackgroundImage is not present
(canvas as fabric.StaticCanvas).backgroundImage = null;
canvas.requestRenderAll();
}
if (backgroundImageUrl) {
try {
const resolvedUrl = backgroundImageUrl.startsWith('http')
? backgroundImageUrl
: `${window.location.origin}${backgroundImageUrl.startsWith('/') ? '' : '/'}${backgroundImageUrl}`;
const image = await new Promise<fabric.Image | null>((resolve) => {
const imgEl = new Image();
imgEl.crossOrigin = 'anonymous';
const timeoutId = window.setTimeout(() => resolve(null), 3000);
imgEl.onload = () => {
window.clearTimeout(timeoutId);
resolve(new fabric.Image(imgEl, { crossOrigin: 'anonymous' }));
};
imgEl.onerror = () => {
window.clearTimeout(timeoutId);
resolve(null);
};
imgEl.src = resolvedUrl;
});
if (image) {
const scaleX = CANVAS_WIDTH / (image.width || CANVAS_WIDTH);
const scaleY = CANVAS_HEIGHT / (image.height || CANVAS_HEIGHT);
const scale = Math.max(scaleX, scaleY);
image.set({
originX: 'left',
originY: 'top',
left: 0,
top: 0,
scaleX: scale,
scaleY: scale,
selectable: false,
evented: false,
});
if (typeof canvas.setBackgroundImage === 'function') {
canvas.setBackgroundImage(image, canvas.requestRenderAll.bind(canvas));
} else {
(canvas as fabric.StaticCanvas).backgroundImage = image;
canvas.requestRenderAll();
}
return;
}
} catch (error) {
console.warn('[Fabric] Failed to load background image', error);
}
}
} catch (error) {
console.warn('[Fabric] applyBackground failed', error);
}
let background: string | fabric.Gradient<'linear'> = color;
if (gradient?.stops?.length) {
@@ -821,15 +890,34 @@ export async function loadImageObject(
const intrinsicWidth = image.width ?? element.width;
const intrinsicHeight = image.height ?? element.height;
const scaleX = element.width / intrinsicWidth;
const scaleY = element.height / intrinsicHeight;
const safeIntrinsicWidth = intrinsicWidth || 1;
const safeIntrinsicHeight = intrinsicHeight || 1;
let targetLeft = element.x;
let targetTop = element.y;
let scaleX = element.width / safeIntrinsicWidth;
let scaleY = element.height / safeIntrinsicHeight;
if (options?.objectFit === 'contain') {
const ratio = Math.min(scaleX, scaleY);
scaleX = ratio;
scaleY = ratio;
const renderedWidth = safeIntrinsicWidth * ratio;
const renderedHeight = safeIntrinsicHeight * ratio;
targetLeft = element.x + (element.width - renderedWidth) / 2;
targetTop = element.y + (element.height - renderedHeight) / 2;
}
image.set({
...baseConfig,
width: element.width,
height: element.height,
originX: 'left',
originY: 'top',
width: safeIntrinsicWidth,
height: safeIntrinsicHeight,
scaleX,
scaleY,
left: targetLeft,
top: targetTop,
padding: options?.padding ?? 0,
});
@@ -837,16 +925,6 @@ export async function loadImageObject(
image.set('shadow', options.shadow);
}
if (options?.objectFit === 'contain') {
const ratio = Math.min(scaleX, scaleY);
image.set({
scaleX: ratio,
scaleY: ratio,
left: element.x + (element.width - intrinsicWidth * ratio) / 2,
top: element.y + (element.height - intrinsicHeight * ratio) / 2,
});
}
resolveSafely(image);
};

View File

@@ -0,0 +1,44 @@
export type BackgroundImageOption = {
id: string;
url: string;
label: string;
};
// Preload background assets from public/storage/layouts/backgrounds.
// Vite does not process the public directory, so we try a glob (for cases where assets are in src)
// and fall back to known public URLs.
const backgroundImports: Record<string, string> = {
...import.meta.glob('../../../../../public/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
eager: true,
as: 'url',
}),
...import.meta.glob('/storage/layouts/backgrounds/*.{jpg,jpeg,png,webp,avif}', {
eager: true,
as: 'url',
}),
};
const fallbackFiles = ['bg-blue-floral.png', 'bg-goldframe.png', 'gr-green-floral.png'];
const importedBackgrounds: BackgroundImageOption[] = Object.entries(backgroundImports).map(([path, url]) => {
const filename = path.split('/').pop() ?? path;
const id = filename.replace(/\.[^.]+$/, '');
return { id, url: url as string, label: filename };
});
const fallbackBackgrounds: BackgroundImageOption[] = fallbackFiles.map((filename) => ({
id: filename.replace(/\.[^.]+$/, ''),
url: `/storage/layouts/backgrounds/${filename}`,
label: filename,
}));
const merged = [...importedBackgrounds, ...fallbackBackgrounds];
export const preloadedBackgrounds: BackgroundImageOption[] = Array.from(
merged.reduce((map, item) => {
if (!map.has(item.id)) {
map.set(item.id, item);
}
return map;
}, new Map<string, BackgroundImageOption>()),
).map(([, value]) => value);

View File

@@ -127,6 +127,7 @@ export type QrLayoutCustomization = {
secondary_color?: string;
badge_color?: string;
background_gradient?: { angle?: number; stops?: string[] } | null;
background_image?: string | null;
logo_data_url?: string | null;
logo_url?: string | null;
mode?: 'standard' | 'advanced';
@@ -172,7 +173,6 @@ const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: nu
const DEFAULT_PRESET: LayoutPreset = [
// Basierend auf dem zentrierten, modernen "confetti-bash"-Layout
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -188,13 +188,11 @@ const DEFAULT_PRESET: LayoutPreset = [
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
{ id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) },
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 40, width: 600, height: 100, align: 'center', fontSize: 32, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 },
];
const evergreenVowsPreset: LayoutPreset = [
// Elegant, linksbündig mit verbesserter Balance
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
{ id: 'badge', type: 'badge', x: (c) => c.canvasWidth - 520 - 120, y: 125, width: 520, height: 90, align: 'right', fontSize: 28, lineHeight: 1.4, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -239,13 +237,11 @@ const evergreenVowsPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 440),
height: (c) => Math.min(c.qrSize, 440),
},
{ id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 40, width: 440, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const midnightGalaPreset: LayoutPreset = [
// Zentriert, premium, mehr vertikaler Abstand
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -268,13 +264,11 @@ const midnightGalaPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat', lineHeight: 1.4 },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const gardenBrunchPreset: LayoutPreset = [
// Verspielt, asymmetrisch, aber ausbalanciert
{ id: 'badge', type: 'badge', x: 120, y: 120, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 },
{
@@ -285,7 +279,6 @@ const gardenBrunchPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 460),
height: (c) => Math.min(c.qrSize, 460),
},
{ id: 'cta', type: 'cta', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 40, width: 460, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{
id: 'description',
type: 'description',
@@ -303,7 +296,6 @@ const gardenBrunchPreset: LayoutPreset = [
const sparklerSoireePreset: LayoutPreset = [
// Festlich, zentriert, klar
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 560) / 2, y: 120, width: 560, height: 90, align: 'center', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -326,14 +318,12 @@ const sparklerSoireePreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 480),
height: (c) => Math.min(c.qrSize, 480),
},
{ id: 'cta', type: 'cta', x: (c) => (c.canvasWidth - 520) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 40, width: 520, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
const confettiBashPreset: LayoutPreset = [
// Zentriertes, luftiges Layout mit klarer Hierarchie.
{ id: 'logo', type: 'logo', x: (c) => (c.canvasWidth - 280) / 2, y: 80, width: 280, height: 140, align: 'center' },
{ id: 'badge', type: 'badge', x: (c) => (c.canvasWidth - 520) / 2, y: 240, width: 520, height: 90, align: 'center', fontSize: 28, lineHeight: 1.4, letterSpacing: 0.5, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -378,18 +368,6 @@ const confettiBashPreset: LayoutPreset = [
width: (c) => Math.min(c.qrSize, 500),
height: (c) => Math.min(c.qrSize, 500),
},
{
id: 'cta',
type: 'cta',
x: (c) => (c.canvasWidth - 600) / 2,
y: (c) => 940 + Math.min(c.qrSize, 500) + 40,
width: 600,
height: 100,
align: 'center',
fontSize: 32,
fontFamily: 'Montserrat',
lineHeight: 1.4,
},
{
id: 'link',
type: 'link',
@@ -407,7 +385,6 @@ const confettiBashPreset: LayoutPreset = [
const balancedModernPreset: LayoutPreset = [
// Wahrhaftig balanciert: Text links, QR rechts
{ id: 'logo', type: 'logo', x: 120, y: 100, width: 300, height: 150, align: 'left' },
{ id: 'badge', type: 'badge', x: 120, y: 270, width: 500, height: 90, align: 'left', fontSize: 28, fontFamily: 'Montserrat' },
{
id: 'headline',
type: 'headline',
@@ -452,7 +429,6 @@ const balancedModernPreset: LayoutPreset = [
width: 480,
height: 480,
},
{ id: 'cta', type: 'cta', x: (c) => c.canvasWidth - 480 - 120, y: 880, width: 480, height: 100, align: 'center', fontSize: 30, fontFamily: 'Montserrat' },
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
];
@@ -501,9 +477,7 @@ export function buildDefaultElements(
headline: form.headline ?? eventName,
subtitle: form.subtitle ?? layout.subtitle ?? '',
description: form.description ?? layout.description ?? '',
badge: form.badge_label ?? layout.badge_label ?? 'Digitale Gästebox',
link: form.link_label ?? '',
cta: form.cta_label ?? layout.cta_label ?? 'Scan mich & starte direkt',
instructions_heading: instructionsHeading,
instructions_text: instructionsList[0] ?? null,
};
@@ -541,15 +515,9 @@ export function buildDefaultElements(
case 'description':
element.content = baseContent.description;
break;
case 'badge':
element.content = baseContent.badge;
break;
case 'link':
element.content = baseContent.link;
break;
case 'cta':
element.content = baseContent.cta;
break;
case 'text-strip':
element.content = instructionsList.join('\n').trim() || layout.description || 'Nutze diesen Bereich für zusätzliche Hinweise oder Storytelling.';
break;

View File

@@ -0,0 +1,35 @@
import { useMemo } from 'react';
type RateBucket = 'coupon' | 'voucher';
export function useRateLimitHelper(bucket: RateBucket) {
return useMemo(() => {
const key = (code: string) => `${bucket}:${code.toUpperCase()}`;
return {
isLimited: (code: string): boolean => {
const item = localStorage.getItem(key(code));
if (!item) return false;
const parsed = JSON.parse(item) as { attempts: number; ts: number };
const ageSeconds = (Date.now() - parsed.ts) / 1000;
if (ageSeconds > 300) {
localStorage.removeItem(key(code));
return false;
}
return parsed.attempts >= 3;
},
bump: (code: string): void => {
const item = localStorage.getItem(key(code));
if (!item) {
localStorage.setItem(key(code), JSON.stringify({ attempts: 1, ts: Date.now() }));
return;
}
const parsed = JSON.parse(item) as { attempts: number; ts: number };
localStorage.setItem(key(code), JSON.stringify({ attempts: (parsed.attempts || 0) + 1, ts: Date.now() }));
},
clear: (code: string): void => {
localStorage.removeItem(key(code));
},
};
}, [bucket]);
}

View File

@@ -23,6 +23,19 @@ export type GiftVoucherCheckoutResponse = {
id: string | null;
};
export type GiftVoucherLookupResponse = {
code: string;
amount: number;
currency: string;
expires_at: string | null;
recipient_name?: string | null;
recipient_email?: string | null;
purchaser_email?: string | null;
status: string;
redeemed_at?: string | null;
refunded_at?: string | null;
};
export async function fetchGiftVoucherTiers(): Promise<GiftVoucherTier[]> {
const response = await fetch('/api/v1/marketing/gift-vouchers/tiers', {
headers: {
@@ -61,3 +74,45 @@ export async function createGiftVoucherCheckout(data: GiftVoucherCheckoutRequest
return payload as GiftVoucherCheckoutResponse;
}
export async function fetchGiftVoucherByCheckout(checkoutId?: string | null, transactionId?: string | null): Promise<GiftVoucherLookupResponse | null> {
if (!checkoutId && !transactionId) {
return null;
}
const params = new URLSearchParams();
if (checkoutId) params.set('checkout_id', checkoutId);
if (transactionId) params.set('transaction_id', transactionId);
const response = await fetch(`/api/v1/marketing/gift-vouchers/lookup?${params.toString()}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return null;
}
const payload = await response.json();
return (payload?.data ?? null) as GiftVoucherLookupResponse | null;
}
export async function fetchGiftVoucherByCode(code: string): Promise<GiftVoucherLookupResponse | null> {
const trimmed = code.trim();
if (!trimmed) {
return null;
}
const params = new URLSearchParams({ code: trimmed });
const response = await fetch(`/api/v1/marketing/gift-vouchers/lookup?${params.toString()}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return null;
}
const payload = await response.json();
return (payload?.data ?? null) as GiftVoucherLookupResponse | null;
}

View File

@@ -7,14 +7,22 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { fetchGiftVoucherTiers, createGiftVoucherCheckout, type GiftVoucherTier } from '@/lib/giftVouchers';
import {
fetchGiftVoucherTiers,
createGiftVoucherCheckout,
fetchGiftVoucherByCode,
type GiftVoucherTier,
type GiftVoucherLookupResponse,
} from '@/lib/giftVouchers';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { cn } from '@/lib/utils';
import { useRateLimitHelper } from '@/hooks/useRateLimitHelper';
function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) {
const [tiers, setTiers] = React.useState<GiftVoucherTier[]>(initial);
const [loading, setLoading] = React.useState(initial.length === 0);
const [error, setError] = React.useState<string | null>(null);
const { locale } = useLocalizedRoutes();
React.useEffect(() => {
if (initial.length > 0) {
@@ -22,10 +30,14 @@ function useGiftVoucherTiers(initial: GiftVoucherTier[] = []) {
return;
}
fetchGiftVoucherTiers()
.then(setTiers)
.then((data) => {
const preferredCurrency = locale === 'en' ? 'USD' : 'EUR';
const preferred = data.filter((tier) => tier.currency === preferredCurrency && tier.can_checkout);
setTiers(preferred.length > 0 ? preferred : data);
})
.catch((err) => setError(err?.message || 'Failed to load tiers'))
.finally(() => setLoading(false));
}, [initial]);
}, [initial, locale]);
return { tiers, loading, error };
}
@@ -45,6 +57,11 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
accept_terms: false,
});
const [errors, setErrors] = React.useState<Record<string, string | null>>({});
const [lookupCode, setLookupCode] = React.useState('');
const [lookupResult, setLookupResult] = React.useState<GiftVoucherLookupResponse | null>(null);
const [lookupError, setLookupError] = React.useState<string | null>(null);
const [lookupLoading, setLookupLoading] = React.useState(false);
const rateLimit = useRateLimitHelper('voucher');
const selectedTierKey = form.tier_key;
@@ -96,6 +113,10 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
return_url: returnUrl,
});
if (response.id) {
sessionStorage.setItem('gift_checkout_id', response.id);
}
if (response.checkout_url) {
window.location.assign(response.checkout_url);
} else {
@@ -108,6 +129,32 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
}
};
const onLookup = async () => {
if (rateLimit.isLimited(lookupCode)) {
setLookupError(t('gift.too_many_attempts'));
return;
}
setLookupLoading(true);
setLookupError(null);
setLookupResult(null);
try {
const result = await fetchGiftVoucherByCode(lookupCode);
if (result) {
setLookupResult(result);
rateLimit.clear(lookupCode);
} else {
setLookupError(t('gift.lookup_not_found'));
rateLimit.bump(lookupCode);
}
} catch (error: any) {
setLookupError(error?.message || t('gift.lookup_not_found'));
rateLimit.bump(lookupCode);
} finally {
setLookupLoading(false);
}
};
return (
<MarketingLayout title={t('gift.title')}>
<section className="relative overflow-hidden bg-gradient-to-b from-background via-muted/40 to-background">
@@ -147,7 +194,6 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
selectedTierKey === tier.key ? 'border-primary shadow-lg' : '',
!tier.can_checkout && 'opacity-60'
)}
onClick={() => tier.can_checkout && setValue('tier_key', tier.key, { shouldValidate: true })}
onClick={() => tier.can_checkout && updateField('tier_key', tier.key)}
>
<CardHeader>
@@ -247,6 +293,53 @@ function GiftVoucherPage({ tiers: initialTiers = [] }: { tiers: GiftVoucherTier[
</Button>
</CardFooter>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>{t('gift.lookup_title')}</CardTitle>
<CardDescription>{t('gift.lookup_subtitle')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-[2fr,1fr,auto]">
<div className="space-y-1 md:col-span-2">
<Label htmlFor="lookup_code">{t('gift.lookup_label')}</Label>
<Input
id="lookup_code"
placeholder="GIFT-XXXXXX"
value={lookupCode}
onChange={(e) => setLookupCode(e.target.value)}
/>
</div>
<div className="flex items-end">
<Button onClick={onLookup} disabled={lookupLoading || !lookupCode.trim()}>
{lookupLoading ? t('gift.processing') : t('gift.lookup_cta')}
</Button>
</div>
{lookupError && <p className="md:col-span-3 text-sm text-destructive">{lookupError}</p>}
{lookupResult && (
<div className="md:col-span-3 space-y-1 rounded-lg border bg-muted/40 p-4">
<p className="text-sm font-semibold">
{t('gift.lookup_result_code', { code: lookupResult.code })}
</p>
<p className="text-sm text-muted-foreground">
{t('gift.lookup_result_value', {
amount: lookupResult.amount.toFixed(2),
currency: lookupResult.currency,
})}
</p>
{lookupResult.expires_at && (
<p className="text-sm text-muted-foreground">
{t('gift.lookup_result_expires', {
date: new Date(lookupResult.expires_at).toLocaleDateString(locale || undefined),
})}
</p>
)}
<p className="text-sm text-muted-foreground">
{t(`gift.lookup_status.${lookupResult.status}`, lookupResult.status)}
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</section>

View File

@@ -7,6 +7,7 @@ import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useLocale } from '@/hooks/useLocale';
import { ADMIN_HOME_PATH } from '@/admin/constants';
import { Button } from '@/components/ui/button';
import { fetchGiftVoucherByCheckout, type GiftVoucherLookupResponse } from '@/lib/giftVouchers';
type SuccessProps = {
type?: string;
@@ -16,6 +17,58 @@ const GiftSuccess: React.FC = () => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const locale = useLocale();
const [voucher, setVoucher] = React.useState<GiftVoucherLookupResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const checkoutId = params.get('checkout_id') || sessionStorage.getItem('gift_checkout_id');
const transactionId = params.get('transaction_id');
fetchGiftVoucherByCheckout(checkoutId || undefined, transactionId || undefined)
.then((data) => {
if (data) {
setVoucher(data);
} else {
setError(t('success.gift_lookup_failed'));
}
})
.catch(() => setError(t('success.gift_lookup_failed')))
.finally(() => setLoading(false));
}, [t]);
const onCopy = async () => {
if (!voucher?.code) return;
try {
await navigator.clipboard.writeText(voucher.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
setError(t('success.gift_copy_failed'));
}
};
const onShare = async () => {
if (!voucher?.code) return;
const text = t('success.gift_share_text', {
code: voucher.code,
amount: voucher.amount.toFixed(2),
currency: voucher.currency,
});
if (navigator.share) {
try {
await navigator.share({ title: t('success.gift_title'), text });
return;
} catch (e) {
// fall back to copy
}
}
await onCopy();
};
return (
<MarketingLayout title={t('success.gift_title')}>
@@ -24,6 +77,42 @@ const GiftSuccess: React.FC = () => {
<CheckCircle className="mx-auto h-12 w-12 text-green-500" />
<h1 className="text-3xl font-bold text-foreground">{t('success.gift_title')}</h1>
<p className="text-muted-foreground">{t('success.gift_description')}</p>
<div className="rounded-xl border bg-card p-6 text-left shadow-sm">
<h2 className="text-lg font-semibold">{t('success.gift_code_title')}</h2>
{loading && <p className="text-muted-foreground">{t('success.gift_loading')}</p>}
{error && <p className="text-destructive">{error}</p>}
{voucher && (
<div className="mt-3 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-muted/50 px-4 py-3">
<div className="text-left">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('success.gift_code_label')}</p>
<p className="font-mono text-lg font-bold">{voucher.code}</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onCopy}>
{copied ? t('success.gift_copied') : t('success.gift_copy')}
</Button>
<Button size="sm" onClick={onShare}>
{t('success.gift_share')}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">
{t('success.gift_value', {
amount: voucher.amount.toFixed(2),
currency: voucher.currency,
})}
</p>
{voucher.expires_at && (
<p className="text-sm text-muted-foreground">
{t('success.gift_expires', {
date: new Date(voucher.expires_at).toLocaleDateString(locale || undefined),
})}
</p>
)}
</div>
)}
</div>
<div className="rounded-xl border bg-card p-6 text-left shadow-sm">
<h2 className="text-lg font-semibold">{t('success.gift_bullets_title')}</h2>
<ul className="mt-3 list-disc space-y-2 pl-5 text-muted-foreground">

View File

@@ -9,6 +9,7 @@ import { Separator } from '@/components/ui/separator';
import { previewCoupon as requestCouponPreview } from '@/lib/coupons';
import type { CouponPreviewResponse } from '@/types/coupon';
import { cn } from '@/lib/utils';
import { useRateLimitHelper } from '@/hooks/useRateLimitHelper';
import toast from 'react-hot-toast';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
@@ -154,6 +155,9 @@ export const PaymentStep: React.FC = () => {
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
const [withdrawalLoading, setWithdrawalLoading] = useState(false);
const [withdrawalError, setWithdrawalError] = useState<string | null>(null);
const RateLimitHelper = useRateLimitHelper('coupon');
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(null);
const [isGiftVoucher, setIsGiftVoucher] = useState(false);
const paddleLocale = useMemo(() => {
const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null);
@@ -177,6 +181,11 @@ export const PaymentStep: React.FC = () => {
return;
}
if (RateLimitHelper.isLimited(trimmed)) {
setCouponError(t('coupon.errors.too_many_attempts'));
return;
}
setCouponLoading(true);
setCouponError(null);
setCouponNotice(null);
@@ -190,6 +199,8 @@ export const PaymentStep: React.FC = () => {
amount: preview.pricing.formatted.discount,
})
);
setVoucherExpiry(preview.coupon.expires_at ?? null);
setIsGiftVoucher(preview.coupon.code?.toUpperCase().startsWith('GIFT-') ?? false);
if (typeof window !== 'undefined') {
localStorage.setItem('preferred_coupon_code', preview.coupon.code);
}
@@ -197,6 +208,7 @@ export const PaymentStep: React.FC = () => {
setCouponPreview(null);
setCouponNotice(null);
setCouponError(error instanceof Error ? error.message : t('coupon.errors.generic'));
RateLimitHelper.bump(trimmed);
} finally {
setCouponLoading(false);
}
@@ -742,9 +754,26 @@ export const PaymentStep: React.FC = () => {
<span>{t('coupon.fields.total')}</span>
<span>{couponPreview.pricing.formatted.total}</span>
</div>
{voucherExpiry && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t('coupon.fields.expires')}</span>
<span>{new Date(voucherExpiry).toLocaleDateString(i18n.language)}</span>
</div>
)}
</div>
</div>
)}
{isGiftVoucher && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-xs text-muted-foreground">
<span>{t('coupon.legal_note')}{' '}</span>
<a
href={i18n.language === 'de' ? '/de/widerrufsbelehrung' : '/en/withdrawal'}
className="text-primary underline"
>
{t('coupon.legal_link')}
</a>
</div>
)}
</div>
{!inlineActive && (