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:
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
35
resources/js/hooks/useRateLimitHelper.ts
Normal file
35
resources/js/hooks/useRateLimitHelper.ts
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -139,6 +139,29 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'gift_voucher' => [
|
||||
'purchaser' => [
|
||||
'subject' => 'Dein Geschenkgutschein (:amount :currency)',
|
||||
'greeting' => 'Danke für deinen Kauf!',
|
||||
'body' => 'Hier ist dein Fotospiel-Geschenkgutschein im Wert von :amount :currency. Teile den Code mit deiner beschenkten Person: :recipient.',
|
||||
'recipient_fallback' => 'dein:e Beschenkte:r',
|
||||
],
|
||||
'recipient' => [
|
||||
'subject' => 'Du hast einen Fotospiel-Geschenkgutschein erhalten (:amount :currency)',
|
||||
'greeting' => 'Du hast ein Geschenk bekommen!',
|
||||
'body' => ':purchaser hat dir einen Fotospiel-Geschenkgutschein im Wert von :amount :currency gesendet. Löse ihn mit dem untenstehenden Code ein.',
|
||||
],
|
||||
'code_label' => 'Gutscheincode',
|
||||
'redeem_hint' => 'Löse den Code beim Checkout für Endkunden-Pakete ein.',
|
||||
'expiry' => 'Gültig bis :date.',
|
||||
'message_title' => 'Persönliche Nachricht',
|
||||
'withdrawal' => 'Widerrufsbelehrung: <a href=":url">Details ansehen</a> (14 Tage; erlischt mit Einlösung).',
|
||||
'footer' => 'Viele Grüße,<br>dein Fotospiel Team',
|
||||
'printable' => 'Druckversion (mit QR)',
|
||||
'reminder' => 'Erinnerung: Dein Gutschein ist noch nicht eingelöst.',
|
||||
'expiry_soon' => 'Hinweis: Dein Gutschein läuft bald ab.',
|
||||
],
|
||||
|
||||
'tenant_feedback' => [
|
||||
'subject' => 'Neues Feedback: :tenant (:sentiment)',
|
||||
'unknown_tenant' => 'Unbekannter Tenant',
|
||||
|
||||
@@ -215,7 +215,37 @@
|
||||
"purchase_complete_title": "Kauf abschließen",
|
||||
"purchase_complete_desc": "Melden Sie sich an, um fortzufahren.",
|
||||
"login": "Anmelden",
|
||||
"no_account": "Kein Konto? Registrieren"
|
||||
"no_account": "Kein Konto? Registrieren",
|
||||
"gift_code_title": "Dein Gutscheincode",
|
||||
"gift_code_label": "Gutscheincode",
|
||||
"gift_loading": "Gutschein wird geladen …",
|
||||
"gift_lookup_failed": "Der Gutschein konnte nicht geladen werden. Bitte prüfe deine Bestätigungs-E-Mail.",
|
||||
"gift_copy": "Code kopieren",
|
||||
"gift_copied": "Kopiert!",
|
||||
"gift_copy_failed": "Konnte nicht kopiert werden. Bitte erneut versuchen.",
|
||||
"gift_share": "Teilen",
|
||||
"gift_value": "Wert: :amount :currency",
|
||||
"gift_expires": "Gültig bis :date",
|
||||
"gift_share_text": "Hier ist dein Fotospiel-Geschenkgutschein. Code: :code (Wert :amount :currency)."
|
||||
},
|
||||
"gift": {
|
||||
"lookup_title": "Gutscheinstatus prüfen",
|
||||
"lookup_subtitle": "Du hast schon einen Code? Prüfe Wert, Gültigkeit und Status.",
|
||||
"lookup_label": "Gutscheincode",
|
||||
"lookup_cta": "Code prüfen",
|
||||
"lookup_not_found": "Gutschein nicht gefunden oder nicht mehr gültig.",
|
||||
"lookup_result_code": "Code: :code",
|
||||
"lookup_result_value": "Wert: :amount :currency",
|
||||
"lookup_result_expires": "Gültig bis :date",
|
||||
"lookup_status": {
|
||||
"issued": "Status: Ausgestellt (einlösbar)",
|
||||
"redeemed": "Status: Eingelöst",
|
||||
"refunded": "Status: Erstattet",
|
||||
"expired": "Status: Abgelaufen",
|
||||
"reminder": "Erinnerung geplant",
|
||||
"expiry": "Ablauf-Hinweis geplant"
|
||||
},
|
||||
"too_many_attempts": "Zu viele Versuche. Bitte kurz warten und erneut probieren."
|
||||
},
|
||||
"blog_show": {
|
||||
"title_suffix": " - Fotospiel Blog",
|
||||
|
||||
@@ -262,6 +262,7 @@ return [
|
||||
'discount' => 'Rabatt',
|
||||
'tax' => 'MwSt.',
|
||||
'total' => 'Gesamtsumme nach Rabatt',
|
||||
'expires' => 'Läuft ab',
|
||||
],
|
||||
'errors' => [
|
||||
'required' => 'Bitte gib einen Gutscheincode ein.',
|
||||
@@ -274,7 +275,10 @@ return [
|
||||
'not_synced' => 'Dieser Gutschein ist noch nicht bereit. Bitte versuche es später erneut.',
|
||||
'package_not_configured' => 'Dieses Package unterstützt aktuell keine Gutscheine.',
|
||||
'login_required' => 'Bitte melde dich an, um diesen Gutschein zu nutzen.',
|
||||
'too_many_attempts' => 'Zu viele Versuche. Bitte kurz warten und erneut versuchen.',
|
||||
'generic' => 'Der Gutschein konnte nicht angewendet werden. Bitte versuche einen anderen.',
|
||||
],
|
||||
'legal_note' => 'Geschenkgutscheine: 14 Tage Widerrufsrecht bis zur Einlösung; siehe Widerrufsbelehrung.',
|
||||
'legal_link' => 'Widerrufsbelehrung öffnen',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -197,4 +197,27 @@ return [
|
||||
'footer' => 'Please review and follow up if needed.',
|
||||
],
|
||||
],
|
||||
|
||||
'gift_voucher' => [
|
||||
'purchaser' => [
|
||||
'subject' => 'Your gift voucher (:amount :currency)',
|
||||
'greeting' => 'Thank you for your purchase!',
|
||||
'body' => 'Here is your Fotospiel gift voucher worth :amount :currency. You can share the code with your recipient: :recipient.',
|
||||
'recipient_fallback' => 'your recipient',
|
||||
],
|
||||
'recipient' => [
|
||||
'subject' => 'You received a Fotospiel gift voucher (:amount :currency)',
|
||||
'greeting' => 'You have a gift!',
|
||||
'body' => ':purchaser sent you a Fotospiel gift voucher worth :amount :currency. Redeem it with the code below.',
|
||||
],
|
||||
'code_label' => 'Voucher code',
|
||||
'redeem_hint' => 'Redeem this code during checkout for any end customer package.',
|
||||
'expiry' => 'Valid until :date.',
|
||||
'message_title' => 'Personal message',
|
||||
'withdrawal' => 'Withdrawal policy: <a href=":url">View details</a> (14 days; expires upon redemption).',
|
||||
'footer' => 'Best regards,<br>The Fotospiel Team',
|
||||
'printable' => 'Printable version (with QR)',
|
||||
'reminder' => 'Reminder: You still have an unused voucher.',
|
||||
'expiry_soon' => 'Heads up: Your voucher will expire soon.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -215,7 +215,37 @@
|
||||
"purchase_complete_title": "Complete Purchase",
|
||||
"purchase_complete_desc": "Log in to continue.",
|
||||
"login": "Login",
|
||||
"no_account": "No Account? Register"
|
||||
"no_account": "No Account? Register",
|
||||
"gift_code_title": "Your gift voucher code",
|
||||
"gift_code_label": "Voucher code",
|
||||
"gift_loading": "Loading your voucher…",
|
||||
"gift_lookup_failed": "We could not load the voucher. Please check your confirmation email.",
|
||||
"gift_copy": "Copy code",
|
||||
"gift_copied": "Copied!",
|
||||
"gift_copy_failed": "Copying failed. Please try again.",
|
||||
"gift_share": "Share",
|
||||
"gift_value": "Value: :amount :currency",
|
||||
"gift_expires": "Valid until :date",
|
||||
"gift_share_text": "Here is your Fotospiel gift voucher. Code: :code (value :amount :currency)."
|
||||
},
|
||||
"gift": {
|
||||
"lookup_title": "Check voucher status",
|
||||
"lookup_subtitle": "Already have a code? See value, validity, and status.",
|
||||
"lookup_label": "Voucher code",
|
||||
"lookup_cta": "Check code",
|
||||
"lookup_not_found": "Voucher not found or no longer valid.",
|
||||
"lookup_result_code": "Code: :code",
|
||||
"lookup_result_value": "Value: :amount :currency",
|
||||
"lookup_result_expires": "Valid until :date",
|
||||
"lookup_status": {
|
||||
"issued": "Status: Issued (ready to redeem)",
|
||||
"redeemed": "Status: Redeemed",
|
||||
"refunded": "Status: Refunded",
|
||||
"expired": "Status: Expired",
|
||||
"reminder": "Reminder scheduled",
|
||||
"expiry": "Expiry reminder scheduled"
|
||||
},
|
||||
"too_many_attempts": "Too many attempts. Please wait a moment and try again."
|
||||
},
|
||||
"blog_show": {
|
||||
"title_suffix": " - Fotospiel Blog",
|
||||
|
||||
@@ -262,6 +262,7 @@ return [
|
||||
'discount' => 'Discount',
|
||||
'tax' => 'Tax',
|
||||
'total' => 'Total after discount',
|
||||
'expires' => 'Expires',
|
||||
],
|
||||
'errors' => [
|
||||
'required' => 'Please enter a coupon code.',
|
||||
@@ -274,7 +275,10 @@ return [
|
||||
'not_synced' => 'This coupon is not ready yet. Please try again later.',
|
||||
'package_not_configured' => 'This package is not available for coupon redemptions.',
|
||||
'login_required' => 'Please log in to use this coupon.',
|
||||
'too_many_attempts' => 'Too many attempts. Please wait a moment and try again.',
|
||||
'generic' => 'We could not apply this coupon. Please try another one.',
|
||||
],
|
||||
'legal_note' => 'Gift vouchers: 14-day withdrawal right until redemption; see withdrawal policy.',
|
||||
'legal_link' => 'Open withdrawal policy',
|
||||
],
|
||||
];
|
||||
|
||||
65
resources/views/emails/gift-voucher.blade.php
Normal file
65
resources/views/emails/gift-voucher.blade.php
Normal file
@@ -0,0 +1,65 @@
|
||||
@php
|
||||
$withdrawalUrl = app()->getLocale() === 'de' ? url('/de/widerrufsbelehrung') : url('/en/withdrawal');
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ $forRecipient ? __('emails.gift_voucher.recipient.subject', ['amount' => $amount, 'currency' => $currency]) : __('emails.gift_voucher.purchaser.subject', ['amount' => $amount, 'currency' => $currency]) }}</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; background-color: #f7f7f7; padding: 20px; color: #111827;">
|
||||
<div style="max-width: 640px; margin: 0 auto; background: #ffffff; border-radius: 10px; padding: 28px; box-shadow: 0 10px 30px rgba(0,0,0,0.05);">
|
||||
<h1 style="margin-top: 0; font-size: 22px;">
|
||||
{{ $forRecipient ? __('emails.gift_voucher.recipient.greeting') : __('emails.gift_voucher.purchaser.greeting') }}
|
||||
</h1>
|
||||
<p style="font-size: 15px; line-height: 1.6; margin-bottom: 16px;">
|
||||
{!! $forRecipient
|
||||
? __('emails.gift_voucher.recipient.body', [
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'purchaser' => $voucher->purchaser_email,
|
||||
])
|
||||
: __('emails.gift_voucher.purchaser.body', [
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'recipient' => $voucher->recipient_email ?: __('emails.gift_voucher.purchaser.recipient_fallback'),
|
||||
])
|
||||
!!}
|
||||
</p>
|
||||
|
||||
@if ($voucher->message)
|
||||
<div style="margin: 18px 0; padding: 14px 16px; background: #f3f4f6; border-left: 4px solid #2563eb; border-radius: 8px;">
|
||||
<strong>{{ __('emails.gift_voucher.message_title') }}</strong>
|
||||
<p style="margin: 8px 0 0; white-space: pre-line;">{{ $voucher->message }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div style="margin: 18px 0; padding: 16px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb;">
|
||||
<p style="margin: 0 0 6px; font-size: 14px; color: #6b7280;">{{ __('emails.gift_voucher.code_label') }}</p>
|
||||
<div style="display: inline-block; padding: 10px 14px; background: #111827; color: #ffffff; border-radius: 8px; font-weight: bold; letter-spacing: 1px;">
|
||||
{{ $voucher->code }}
|
||||
</div>
|
||||
<p style="margin: 10px 0 0; font-size: 14px; color: #4b5563;">
|
||||
{{ __('emails.gift_voucher.redeem_hint') }}
|
||||
</p>
|
||||
@isset($printUrl)
|
||||
<p style="margin: 8px 0 0; font-size: 14px;">
|
||||
<a href="{{ $printUrl }}">{{ __('emails.gift_voucher.printable') }}</a>
|
||||
</p>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #4b5563; margin: 12px 0;">
|
||||
{{ __('emails.gift_voucher.expiry', ['date' => optional($voucher->expires_at)->toFormattedDateString()]) }}
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #4b5563; margin: 12px 0;">
|
||||
{!! __('emails.gift_voucher.withdrawal', ['url' => $withdrawalUrl]) !!}
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #4b5563; margin-top: 20px;">
|
||||
{!! __('emails.gift_voucher.footer') !!}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
62
resources/views/marketing/gift-voucher-print.blade.php
Normal file
62
resources/views/marketing/gift-voucher-print.blade.php
Normal file
@@ -0,0 +1,62 @@
|
||||
@php
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ __('Gift Voucher') }} - {{ $voucher->code }}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #f7f7f7; margin: 0; padding: 20px; color: #111827; }
|
||||
.wrap { max-width: 720px; margin: 0 auto; background: #fff; padding: 28px; border-radius: 14px; box-shadow: 0 10px 30px rgba(0,0,0,0.06); }
|
||||
.badge { display: inline-block; padding: 6px 10px; background: #eef2ff; color: #4338ca; border-radius: 10px; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.code { display: inline-block; padding: 10px 14px; background: #111827; color: #fff; border-radius: 10px; font-weight: bold; letter-spacing: 1px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(240px,1fr)); gap: 16px; margin-top: 18px; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 14px; background: #f9fafb; }
|
||||
.muted { color: #6b7280; font-size: 14px; }
|
||||
.title { font-size: 24px; margin: 10px 0; }
|
||||
.qr { text-align: center; }
|
||||
.qr svg { max-width: 180px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="badge">{{ __('Gift Voucher') }}</div>
|
||||
<h1 class="title">{{ config('app.name') }}</h1>
|
||||
<p class="muted">{{ __('Show or share this page, or scan the QR to redeem the voucher code at checkout.') }}</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<p class="muted">{{ __('Voucher code') }}</p>
|
||||
<div class="code">{{ $voucher->code }}</div>
|
||||
<p class="muted" style="margin-top:12px;">
|
||||
{{ __('Value') }}: {{ number_format((float) $voucher->amount, 2) }} {{ $voucher->currency }}<br>
|
||||
@if($voucher->expires_at)
|
||||
{{ __('Valid until') }}: {{ $voucher->expires_at->toFormattedDateString() }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<div class="card qr">
|
||||
{!! QrCode::size(180)->generate($voucher->code) !!}
|
||||
<p class="muted">{{ __('Scan to redeem code at checkout') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($voucher->recipient_name || $voucher->recipient_email)
|
||||
<div class="card" style="margin-top:16px;">
|
||||
<strong>{{ __('Recipient') }}:</strong>
|
||||
<p class="muted" style="margin:6px 0 0;">
|
||||
{{ $voucher->recipient_name ?? '' }} {{ $voucher->recipient_email ? '('.$voucher->recipient_email.')' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($voucher->message)
|
||||
<div class="card" style="margin-top:16px;">
|
||||
<strong>{{ __('Message') }}</strong>
|
||||
<p style="margin:8px 0 0; white-space: pre-line;">{{ $voucher->message }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user