Implement package limit notification system

This commit is contained in:
Codex Agent
2025-11-01 13:19:07 +01:00
parent 81cdee428e
commit 2c14493604
87 changed files with 4557 additions and 290 deletions

View File

@@ -1,4 +1,5 @@
import { authorizedFetch } from './auth/tokens';
import { ApiError } from './lib/apiError';
import i18n from './i18n';
type JsonValue = Record<string, unknown>;
@@ -331,8 +332,20 @@ type EventSavePayload = {
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
if (!response.ok) {
const body = await safeJson(response);
console.error('[API]', message, response.status, body);
throw new Error(message);
const status = response.status;
const errorPayload = body && typeof body === 'object' ? (body as Record<string, unknown>).error : null;
const errorMessage = (errorPayload && typeof errorPayload === 'object' && 'message' in errorPayload && typeof errorPayload.message === 'string')
? errorPayload.message
: message;
const errorCode = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.code === 'string'
? errorPayload.code
: undefined;
const errorMeta = errorPayload && typeof errorPayload === 'object' && typeof errorPayload.meta === 'object'
? errorPayload.meta as Record<string, unknown>
: undefined;
console.error('[API]', errorMessage, status, body);
throw new ApiError(errorMessage, status, errorCode, errorMeta);
}
return (await response.json()) as T;

View File

@@ -20,5 +20,13 @@
"actions": {
"open": "Öffnen",
"viewAll": "Alle anzeigen"
},
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.",
"eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.",
"creditsExhausted": "Keine Event-Credits mehr verfügbar. Bitte buche Credits oder upgrade dein Paket.",
"photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.",
"goToBilling": "Zur Paketverwaltung"
}
}

View File

@@ -20,5 +20,13 @@
"actions": {
"open": "Open",
"viewAll": "View all"
},
"errors": {
"generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event slots.",
"eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.",
"creditsExhausted": "You have no event credits remaining. Purchase credits or upgrade your package.",
"photoLimit": "This event reached its photo upload limit.",
"goToBilling": "Manage subscription"
}
}

View File

@@ -0,0 +1,15 @@
export class ApiError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string,
public readonly meta?: Record<string, unknown>,
) {
super(message);
this.name = 'ApiError';
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -23,6 +24,7 @@ import {
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { isApiError } from '../lib/apiError';
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
interface EventFormState {
@@ -63,6 +65,8 @@ export default function EventFormPage() {
const isEdit = Boolean(slugParam);
const navigate = useNavigate();
const { t: tCommon } = useTranslation('common', { keyPrefix: 'errors' });
const [form, setForm] = React.useState<EventFormState>({
name: '',
slug: '',
@@ -76,6 +80,7 @@ export default function EventFormPage() {
const slugSuffixRef = React.useRef<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
@@ -232,6 +237,7 @@ export default function EventFormPage() {
setSaving(true);
setError(null);
setShowUpgradeHint(false);
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
const packageIdForSubmit = form.package_id || activePackage?.package_id || null;
@@ -256,14 +262,44 @@ export default function EventFormPage() {
const targetSlug = originalSlug ?? slugParam!;
const updated = await updateEvent(targetSlug, payload);
setOriginalSlug(updated.slug);
setShowUpgradeHint(false);
setError(null);
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
} else {
const { event: created } = await createEvent(payload);
setShowUpgradeHint(false);
setError(null);
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
}
} catch (err) {
if (!isAuthError(err)) {
setError('Speichern fehlgeschlagen. Bitte prüfe deine Eingaben.');
if (isApiError(err)) {
switch (err.code) {
case 'event_limit_exceeded': {
const limit = Number(err.meta?.limit ?? 0);
const used = Number(err.meta?.used ?? 0);
const remaining = Number(err.meta?.remaining ?? Math.max(0, limit - used));
const detail = limit > 0
? tCommon('eventLimitDetails', { used, limit, remaining })
: '';
setError(`${tCommon('eventLimit')}${detail ? `\n${detail}` : ''}`);
setShowUpgradeHint(true);
break;
}
case 'event_credits_exhausted': {
setError(tCommon('creditsExhausted'));
setShowUpgradeHint(true);
break;
}
default: {
setError(err.message || tCommon('generic'));
setShowUpgradeHint(false);
}
}
} else {
setError(tCommon('generic'));
setShowUpgradeHint(false);
}
}
} finally {
setSaving(false);
@@ -360,7 +396,18 @@ export default function EventFormPage() {
{error && (
<Alert variant="destructive">
<AlertTitle>Hinweis</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="flex flex-col gap-2">
{error.split('\n').map((line, index) => (
<span key={index}>{line}</span>
))}
{showUpgradeHint && (
<div>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_BILLING_PATH)}>
{tCommon('goToBilling')}
</Button>
</div>
)}
</AlertDescription>
</Alert>
)}

View File

@@ -31,7 +31,10 @@ import {
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
buildDefaultElements,
clamp,
normalizeElements,
payloadToElements,
LayoutElement,
@@ -171,6 +174,8 @@ export default function EventInvitesPage(): JSX.Element {
const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null);
const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null);
const [exportError, setExportError] = React.useState<string | null>(null);
const exportPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const [exportScale, setExportScale] = React.useState(0.34);
const load = React.useCallback(async () => {
if (!slug) {
@@ -190,10 +195,35 @@ export default function EventInvitesPage(): JSX.Element {
}
}, [slug]);
const recomputeExportScale = React.useCallback(() => {
const container = exportPreviewContainerRef.current;
if (!container) {
return;
}
const widthRatio = container.clientWidth / CANVAS_WIDTH;
const heightRatio = container.clientHeight ? container.clientHeight / CANVAS_HEIGHT : Number.POSITIVE_INFINITY;
const base = Math.min(widthRatio, heightRatio);
const safeBase = Number.isFinite(base) && base > 0 ? Math.min(base, 1) : 1;
const clampedScale = clamp(safeBase, 0.1, 1);
setExportScale((prev) => (Math.abs(prev - clampedScale) < 0.001 ? prev : clampedScale));
}, []);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
recomputeExportScale();
}, [recomputeExportScale]);
React.useEffect(() => {
const handleResize = () => recomputeExportScale();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [recomputeExportScale]);
React.useEffect(() => {
const param = searchParams.get('tab');
const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout';
@@ -369,6 +399,28 @@ export default function EventInvitesPage(): JSX.Element {
);
}, [exportLayout, currentCustomization, selectedInvite?.url, eventName]);
React.useEffect(() => {
if (activeTab !== 'export') {
return;
}
recomputeExportScale();
}, [activeTab, recomputeExportScale, exportElements.length, exportLayout?.id, selectedInvite?.id]);
React.useEffect(() => {
if (typeof ResizeObserver !== 'function') {
return undefined;
}
const target = exportPreviewContainerRef.current;
if (!target) {
return undefined;
}
const observer = new ResizeObserver(() => recomputeExportScale());
observer.observe(target);
return () => observer.disconnect();
}, [recomputeExportScale, activeTab]);
const exportCanvasKey = React.useMemo(
() => `export:${selectedInvite?.id ?? 'none'}:${exportLayout?.id ?? 'layout'}:${exportPreview?.mode ?? 'standard'}`,
[selectedInvite?.id, exportLayout?.id, exportPreview?.mode]
@@ -789,7 +841,10 @@ export default function EventInvitesPage(): JSX.Element {
</div>
<div className="mt-6 flex justify-center">
{exportElements.length ? (
<div className="pointer-events-none">
<div
ref={exportPreviewContainerRef}
className="pointer-events-none w-full max-w-full"
>
<DesignerCanvas
elements={exportElements}
selectedId={null}
@@ -803,8 +858,9 @@ export default function EventInvitesPage(): JSX.Element {
badge={exportPreview.badgeColor}
qrCodeDataUrl={exportQr}
logoDataUrl={exportLogo}
scale={0.34}
scale={exportScale}
layoutKey={exportCanvasKey}
readOnly
/>
</div>
) : (

View File

@@ -34,19 +34,21 @@ import type { EventQrInvite, EventQrInviteLayout } from '../../api';
import { authorizedFetch } from '../../auth/tokens';
import {
CANVAS_HEIGHT,
CANVAS_WIDTH,
QrLayoutCustomization,
LayoutElement,
LayoutElementPayload,
LayoutElementType,
LayoutSerializationContext,
buildDefaultElements,
clamp,
clampElement,
elementsToPayload,
normalizeElements,
payloadToElements,
} from './invite-layout/schema';
import { DesignerCanvas } from './invite-layout/DesignerCanvas';
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './invite-layout/schema';
import {
generatePdfBytes,
generatePngDataUrl,
@@ -181,6 +183,9 @@ type InviteLayoutCustomizerPanelProps = {
};
const MAX_INSTRUCTIONS = 5;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.05;
export function InviteLayoutCustomizerPanel({
invite,
@@ -213,6 +218,10 @@ export function InviteLayoutCustomizerPanel({
const [elements, setElements] = React.useState<LayoutElement[]>([]);
const [activeElementId, setActiveElementId] = React.useState<string | null>(null);
const [showFloatingActions, setShowFloatingActions] = React.useState(false);
const [zoomScale, setZoomScale] = React.useState(1);
const [fitScale, setFitScale] = React.useState(1);
const fitScaleRef = React.useRef(1);
const manualZoomRef = React.useRef(false);
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
const historyRef = React.useRef<LayoutElement[][]>([]);
const historyIndexRef = React.useRef(-1);
@@ -223,6 +232,83 @@ export function InviteLayoutCustomizerPanel({
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
const isAdvanced = true;
const clampZoom = React.useCallback(
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
[],
);
const recomputeFitScale = React.useCallback(() => {
const viewport = designerViewportRef.current;
if (!viewport) {
return;
}
const { clientWidth, clientHeight } = viewport;
if (!clientWidth || !clientHeight) {
return;
}
const style = window.getComputedStyle(viewport);
const paddingX = parseFloat(style.paddingLeft ?? '0') + parseFloat(style.paddingRight ?? '0');
const paddingY = parseFloat(style.paddingTop ?? '0') + parseFloat(style.paddingBottom ?? '0');
const availableWidth = clientWidth - paddingX;
const availableHeight = clientHeight - paddingY;
if (availableWidth <= 0 || availableHeight <= 0) {
return;
}
const widthScale = availableWidth / CANVAS_WIDTH;
const heightScale = availableHeight / CANVAS_HEIGHT;
const nextRaw = Math.min(widthScale, heightScale);
const baseScale = Number.isFinite(nextRaw) && nextRaw > 0 ? Math.min(nextRaw, 1) : 1;
const clamped = clampZoom(baseScale);
fitScaleRef.current = clamped;
setFitScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
if (!manualZoomRef.current) {
setZoomScale((prev) => (Math.abs(prev - clamped) < 0.001 ? prev : clamped));
}
console.debug('[Invites][Zoom] viewport size', {
availableWidth,
availableHeight,
widthScale,
heightScale,
clamped,
});
}, [clampZoom]);
React.useLayoutEffect(() => {
recomputeFitScale();
}, [recomputeFitScale]);
React.useEffect(() => {
const viewport = designerViewportRef.current;
const handleResize = () => {
recomputeFitScale();
};
window.addEventListener('resize', handleResize);
let observer: ResizeObserver | null = null;
if (viewport && typeof ResizeObserver === 'function') {
observer = new ResizeObserver(() => recomputeFitScale());
observer.observe(viewport);
}
recomputeFitScale();
return () => {
window.removeEventListener('resize', handleResize);
if (observer) {
observer.disconnect();
}
};
}, [recomputeFitScale]);
const cloneElements = React.useCallback(
(items: LayoutElement[]): LayoutElement[] => items.map((item) => ({ ...item })),
[]
@@ -355,6 +441,11 @@ export function InviteLayoutCustomizerPanel({
return availableLayouts[0];
}, [availableLayouts, selectedLayoutId]);
React.useEffect(() => {
manualZoomRef.current = false;
recomputeFitScale();
}, [recomputeFitScale, activeLayout?.id, invite?.id]);
const activeLayoutQrSize = React.useMemo(() => {
const qrElement = elements.find((element) => element.type === 'qr');
if (qrElement && typeof qrElement.width === 'number' && qrElement.width > 0) {
@@ -371,6 +462,12 @@ export function InviteLayoutCustomizerPanel({
return activeLayout?.preview?.qr_size_px ?? 500;
}, [elements, initialCustomization?.mode, initialCustomization?.elements, activeLayout?.preview?.qr_size_px]);
const effectiveScale = React.useMemo(
() => clampZoom(Number.isFinite(zoomScale) && zoomScale > 0 ? zoomScale : fitScale),
[clampZoom, zoomScale, fitScale],
);
const zoomPercent = Math.round(effectiveScale * 100);
const updateElement = React.useCallback(
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
commitElements(
@@ -1702,27 +1799,62 @@ export function InviteLayoutCustomizerPanel({
<div ref={actionsSentinelRef} className="h-1 w-full" />
</form>
<div className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">
{t('invites.customizer.controls.zoom', 'Zoom')}
</span>
<input
type="range"
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={effectiveScale}
onChange={(event) => {
manualZoomRef.current = true;
setZoomScale(clampZoom(Number(event.target.value)));
}}
className="h-1 w-36 overflow-hidden rounded-full"
disabled={false}
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
/>
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
manualZoomRef.current = false;
const fitValue = clampZoom(fitScaleRef.current);
setZoomScale(fitValue);
}}
disabled={Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
>
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUndo}
disabled={!canUndo}
>
<Undo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.undo', 'Rückgängig')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRedo}
disabled={!canRedo}
>
<Redo2 className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.redo', 'Wiederholen')}
</Button>
</div>
</div>
<div className="flex justify-center">
@@ -1744,6 +1876,7 @@ export function InviteLayoutCustomizerPanel({
badge={form.badge_color ?? form.accent_color ?? '#2563EB'}
qrCodeDataUrl={qrCodeDataUrl}
logoDataUrl={form.logo_data_url ?? form.logo_url ?? null}
scale={effectiveScale}
layoutKey={`designer:${invite?.id ?? 'unknown'}:${activeLayout?.id ?? 'default'}`}
/>
</div>

View File

@@ -22,6 +22,7 @@ type DesignerCanvasProps = {
badge: string;
qrCodeDataUrl: string | null;
logoDataUrl: string | null;
scale?: number;
layoutKey?: string;
readOnly?: boolean;
};
@@ -41,6 +42,7 @@ export function DesignerCanvas({
badge,
qrCodeDataUrl,
logoDataUrl,
scale = 1,
layoutKey,
readOnly = false,
}: DesignerCanvasProps): React.JSX.Element {
@@ -343,16 +345,43 @@ export function DesignerCanvas({
if (!canvas) {
return;
}
canvas.setZoom(1);
canvas.setDimensions(
{
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
},
{ cssOnly: true },
);
const normalizedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
canvas.setZoom(normalizedScale);
const cssWidth = CANVAS_WIDTH * normalizedScale;
const cssHeight = CANVAS_HEIGHT * normalizedScale;
const element = canvas.getElement();
if (element) {
element.style.width = `${cssWidth}px`;
element.style.height = `${cssHeight}px`;
}
if (canvas.upperCanvasEl) {
canvas.upperCanvasEl.style.width = `${cssWidth}px`;
canvas.upperCanvasEl.style.height = `${cssHeight}px`;
}
if (canvas.lowerCanvasEl) {
canvas.lowerCanvasEl.style.width = `${cssWidth}px`;
canvas.lowerCanvasEl.style.height = `${cssHeight}px`;
}
if (canvas.wrapperEl) {
canvas.wrapperEl.style.width = `${cssWidth}px`;
canvas.wrapperEl.style.height = `${cssHeight}px`;
}
if (containerRef.current) {
containerRef.current.style.width = `${cssWidth}px`;
containerRef.current.style.height = `${cssHeight}px`;
}
canvas.calcOffset();
canvas.requestRenderAll();
}, []);
}, [scale]);
return (
<div ref={containerRef} className="relative inline-block max-w-full">

View File

@@ -133,217 +133,361 @@ export function clampElement(element: LayoutElement): LayoutElement {
}
const DEFAULT_TYPE_STYLES: Record<LayoutElementType, { width: number; height: number; fontSize?: number; align?: LayoutTextAlign; locked?: boolean }> = {
headline: { width: 620, height: 200, fontSize: 68, align: 'left' },
subtitle: { width: 580, height: 140, fontSize: 34, align: 'left' },
description: { width: 620, height: 280, fontSize: 28, align: 'left' },
link: { width: 400, height: 110, fontSize: 28, align: 'center' },
badge: { width: 280, height: 80, fontSize: 24, align: 'center' },
logo: { width: 240, height: 180, align: 'center' },
cta: { width: 400, height: 110, fontSize: 26, align: 'center' },
qr: { width: 520, height: 520 },
text: { width: 560, height: 200, fontSize: 26, align: 'left' },
headline: { width: 900, height: 240, fontSize: 82, align: 'left' },
subtitle: { width: 760, height: 170, fontSize: 40, align: 'left' },
description: { width: 920, height: 340, fontSize: 32, align: 'left' },
link: { width: 520, height: 130, fontSize: 30, align: 'center' },
badge: { width: 420, height: 100, fontSize: 26, align: 'center' },
logo: { width: 320, height: 220, align: 'center' },
cta: { width: 520, height: 130, fontSize: 28, align: 'center' },
qr: { width: 640, height: 640 },
text: { width: 720, height: 260, fontSize: 28, align: 'left' },
};
const DEFAULT_PRESET: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 120, y: 140, width: 320, height: 80, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 120, y: 260, width: 620, height: 200, fontSize: 68, align: 'left' },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 440, width: 600, height: 140, fontSize: 34, align: 'left' },
{ id: 'description', type: 'description', x: 120, y: 600, width: 620, height: 280, fontSize: 28, align: 'left' },
{ id: 'badge', type: 'badge', x: 140, y: 160, width: 440, height: 100, align: 'center', fontSize: 28 },
{
id: 'headline',
type: 'headline',
x: 140,
y: 300,
width: (context) => context.canvasWidth - 280,
height: 240,
fontSize: 84,
align: 'left',
},
{
id: 'subtitle',
type: 'subtitle',
x: 140,
y: 560,
width: (context) => context.canvasWidth - 280,
height: 170,
fontSize: 42,
align: 'left',
},
{
id: 'description',
type: 'description',
x: 140,
y: 750,
width: (context) => context.canvasWidth - 280,
height: 340,
fontSize: 32,
align: 'left',
},
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 140,
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 180,
y: 360,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
width: (context) => Math.min(context.qrSize, 680),
height: (context) => Math.min(context.qrSize, 680),
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 420,
y: (context) => 400 + context.qrSize,
width: 400,
height: 110,
x: (context) => context.canvasWidth - 540,
y: (context) => 420 + Math.min(context.qrSize, 680),
width: 520,
height: 130,
fontSize: 28,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 420,
y: (context) => 420 + context.qrSize + 140,
width: 400,
height: 110,
fontSize: 26,
x: (context) => context.canvasWidth - 540,
y: (context) => 460 + Math.min(context.qrSize, 680) + 160,
width: 520,
height: 130,
fontSize: 30,
align: 'center',
},
];
const evergreenVowsPreset: LayoutPreset = [
{ id: 'logo', type: 'logo', x: 120, y: 140, width: 240, height: 180 },
{ id: 'badge', type: 'badge', x: 400, y: 160, width: 320, height: 80, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 120, y: 360, width: 620, height: 220, fontSize: 70, align: 'left' },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 560, width: 600, height: 140, fontSize: 34, align: 'left' },
{ id: 'description', type: 'description', x: 120, y: 720, width: 620, height: 280, fontSize: 28, align: 'left' },
{ id: 'logo', type: 'logo', x: 160, y: 140, width: 340, height: 240 },
{ id: 'badge', type: 'badge', x: 540, y: 160, width: 420, height: 100, align: 'center', fontSize: 28 },
{
id: 'headline',
type: 'headline',
x: 160,
y: 360,
width: (context) => context.canvasWidth - 320,
height: 250,
fontSize: 86,
align: 'left',
},
{
id: 'subtitle',
type: 'subtitle',
x: 160,
y: 630,
width: (context) => context.canvasWidth - 320,
height: 180,
fontSize: 42,
align: 'left',
},
{
id: 'description',
type: 'description',
x: 160,
y: 840,
width: (context) => context.canvasWidth - 320,
height: 360,
fontSize: 34,
align: 'left',
},
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 160,
y: 460,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
x: (context) => context.canvasWidth - Math.min(context.qrSize, 640) - 200,
y: 420,
width: (context) => Math.min(context.qrSize, 640),
height: (context) => Math.min(context.qrSize, 640),
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 420,
y: (context) => 500 + context.qrSize,
width: 400,
height: 110,
x: (context) => context.canvasWidth - 560,
y: (context) => 480 + Math.min(context.qrSize, 640),
width: 520,
height: 130,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 420,
y: (context) => 520 + context.qrSize + 150,
width: 400,
height: 110,
x: (context) => context.canvasWidth - 560,
y: (context) => 520 + Math.min(context.qrSize, 640) + 180,
width: 520,
height: 130,
align: 'center',
},
];
const midnightGalaPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 360, y: 160, width: 520, height: 90, align: 'center', fontSize: 26 },
{ id: 'headline', type: 'headline', x: 220, y: 300, width: 800, height: 220, fontSize: 76, align: 'center' },
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 36, align: 'center' },
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 300, y: 180, width: 600, height: 120, align: 'center', fontSize: 32 },
{
id: 'headline',
type: 'headline',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
y: 340,
width: (context) => context.canvasWidth - 220,
height: 260,
fontSize: 90,
align: 'center',
},
{
id: 'subtitle',
type: 'subtitle',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
y: 640,
width: (context) => context.canvasWidth - 320,
height: 200,
fontSize: 46,
align: 'center',
},
{
id: 'qr',
type: 'qr',
x: (context) => (context.canvasWidth - context.qrSize) / 2,
y: 700,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 640)) / 2,
y: 880,
width: (context) => Math.min(context.qrSize, 640),
height: (context) => Math.min(context.qrSize, 640),
},
{
id: 'link',
type: 'link',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 740 + context.qrSize,
width: 420,
height: 120,
x: (context) => (context.canvasWidth - 560) / 2,
y: (context) => 940 + Math.min(context.qrSize, 640),
width: 560,
height: 140,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 770 + context.qrSize + 150,
width: 420,
height: 120,
x: (context) => (context.canvasWidth - 560) / 2,
y: (context) => 980 + Math.min(context.qrSize, 640) + 200,
width: 560,
height: 140,
align: 'center',
},
{
id: 'description',
type: 'description',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 240) / 2,
y: 1250,
width: (context) => context.canvasWidth - 240,
height: 360,
fontSize: 34,
align: 'center',
},
{ id: 'description', type: 'description', x: 200, y: 1040, width: 840, height: 260, fontSize: 28, align: 'center' },
];
const gardenBrunchPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 160, y: 160, width: 360, height: 80, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 160, y: 300, width: 560, height: 200, fontSize: 66, align: 'left' },
{ id: 'description', type: 'description', x: 160, y: 520, width: 560, height: 260, fontSize: 28, align: 'left' },
{ id: 'badge', type: 'badge', x: 180, y: 180, width: 500, height: 110, align: 'center', fontSize: 30 },
{ id: 'headline', type: 'headline', x: 180, y: 340, width: (context) => context.canvasWidth - 360, height: 260, fontSize: 86, align: 'left' },
{ id: 'description', type: 'description', x: 180, y: 630, width: (context) => context.canvasWidth - 360, height: 360, fontSize: 34, align: 'left' },
{
id: 'qr',
type: 'qr',
x: 160,
y: 840,
width: (context) => Math.min(context.qrSize, 520),
height: (context) => Math.min(context.qrSize, 520),
x: 180,
y: 1000,
width: (context) => Math.min(context.qrSize, 660),
height: (context) => Math.min(context.qrSize, 660),
},
{
id: 'link',
type: 'link',
x: 160,
y: (context) => 880 + Math.min(context.qrSize, 520),
width: 420,
height: 110,
x: 180,
y: (context) => 1060 + Math.min(context.qrSize, 660),
width: 520,
height: 140,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: 160,
y: (context) => 910 + Math.min(context.qrSize, 520) + 140,
width: 420,
height: 110,
x: 180,
y: (context) => 1100 + Math.min(context.qrSize, 660) + 190,
width: 520,
height: 140,
align: 'center',
},
{ id: 'subtitle', type: 'subtitle', x: 780, y: 320, width: 320, height: 140, fontSize: 32, align: 'left' },
{ id: 'text-strip', type: 'text', x: 780, y: 480, width: 320, height: 320, fontSize: 24, align: 'left' },
{ id: 'subtitle', type: 'subtitle', x: (context) => context.canvasWidth - 460, y: 360, width: 420, height: 200, fontSize: 38, align: 'left' },
{ id: 'text-strip', type: 'text', x: (context) => context.canvasWidth - 460, y: 620, width: 420, height: 360, fontSize: 28, align: 'left' },
];
const sparklerSoireePreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 360, y: 150, width: 520, height: 90, align: 'center', fontSize: 26 },
{ id: 'headline', type: 'headline', x: 200, y: 300, width: 840, height: 220, fontSize: 72, align: 'center' },
{ id: 'subtitle', type: 'subtitle', x: 260, y: 520, width: 720, height: 140, fontSize: 34, align: 'center' },
{ id: 'description', type: 'description', x: 220, y: 680, width: 800, height: 240, fontSize: 28, align: 'center' },
{ id: 'badge', type: 'badge', x: (context) => context.canvasWidth / 2 - 320, y: 200, width: 640, height: 120, align: 'center', fontSize: 32 },
{
id: 'headline',
type: 'headline',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 220) / 2,
y: 360,
width: (context) => context.canvasWidth - 220,
height: 280,
fontSize: 94,
align: 'center',
},
{
id: 'subtitle',
type: 'subtitle',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
y: 660,
width: (context) => context.canvasWidth - 320,
height: 210,
fontSize: 46,
align: 'center',
},
{
id: 'description',
type: 'description',
x: (context) => context.canvasWidth / 2 - (context.canvasWidth - 320) / 2,
y: 920,
width: (context) => context.canvasWidth - 320,
height: 380,
fontSize: 34,
align: 'center',
},
{
id: 'qr',
type: 'qr',
x: (context) => (context.canvasWidth - context.qrSize) / 2,
y: 960,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
x: (context) => (context.canvasWidth - Math.min(context.qrSize, 680)) / 2,
y: 1200,
width: (context) => Math.min(context.qrSize, 680),
height: (context) => Math.min(context.qrSize, 680),
},
{
id: 'link',
type: 'link',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 1000 + context.qrSize,
width: 420,
height: 110,
x: (context) => (context.canvasWidth - 580) / 2,
y: (context) => 1260 + Math.min(context.qrSize, 680),
width: 580,
height: 150,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => (context.canvasWidth - 420) / 2,
y: (context) => 1030 + context.qrSize + 140,
width: 420,
height: 110,
x: (context) => (context.canvasWidth - 580) / 2,
y: (context) => 1300 + Math.min(context.qrSize, 680) + 200,
width: 580,
height: 150,
align: 'center',
},
];
const confettiBashPreset: LayoutPreset = [
{ id: 'badge', type: 'badge', x: 140, y: 180, width: 360, height: 90, align: 'center', fontSize: 24 },
{ id: 'headline', type: 'headline', x: 140, y: 320, width: 520, height: 220, fontSize: 68, align: 'left' },
{ id: 'subtitle', type: 'subtitle', x: 140, y: 520, width: 520, height: 140, fontSize: 34, align: 'left' },
{ id: 'description', type: 'description', x: 140, y: 680, width: 520, height: 240, fontSize: 26, align: 'left' },
{ id: 'badge', type: 'badge', x: 180, y: 220, width: 520, height: 120, align: 'center', fontSize: 32 },
{
id: 'headline',
type: 'headline',
x: 180,
y: 380,
width: (context) => context.canvasWidth - 360,
height: 260,
fontSize: 90,
align: 'left',
},
{
id: 'subtitle',
type: 'subtitle',
x: 180,
y: 660,
width: (context) => context.canvasWidth - 360,
height: 200,
fontSize: 46,
align: 'left',
},
{
id: 'description',
type: 'description',
x: 180,
y: 910,
width: (context) => context.canvasWidth - 360,
height: 360,
fontSize: 34,
align: 'left',
},
{
id: 'qr',
type: 'qr',
x: (context) => context.canvasWidth - context.qrSize - 200,
y: 360,
width: (context) => context.qrSize,
height: (context) => context.qrSize,
x: (context) => context.canvasWidth - Math.min(context.qrSize, 680) - 200,
y: 460,
width: (context) => Math.min(context.qrSize, 680),
height: (context) => Math.min(context.qrSize, 680),
},
{
id: 'link',
type: 'link',
x: (context) => context.canvasWidth - 420,
y: (context) => 400 + context.qrSize,
width: 400,
height: 110,
x: (context) => context.canvasWidth - 560,
y: (context) => 520 + Math.min(context.qrSize, 680),
width: 520,
height: 140,
align: 'center',
},
{
id: 'cta',
type: 'cta',
x: (context) => context.canvasWidth - 420,
y: (context) => 430 + context.qrSize + 140,
width: 400,
height: 110,
x: (context) => context.canvasWidth - 560,
y: (context) => 560 + Math.min(context.qrSize, 680) + 200,
width: 520,
height: 140,
align: 'center',
},
{ id: 'text-strip', type: 'text', x: 140, y: 960, width: 860, height: 220, fontSize: 26, align: 'left' },
{
id: 'text-strip',
type: 'text',
x: 180,
y: 1220,
width: (context) => context.canvasWidth - 360,
height: 360,
fontSize: 30,
align: 'left',
},
];
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
@@ -513,6 +657,7 @@ export function elementsToPayload(elements: LayoutElement[]): LayoutElementPaylo
}));
}
export function normalizeElements(elements: LayoutElement[]): LayoutElement[] {
const seen = new Set<string>();
return elements

View File

@@ -297,6 +297,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
limitUnlimited: 'unbegrenzt',
limitWarning: 'Nur noch {remaining} von {max} Fotos möglich. Bitte kontaktiere die Veranstalter für ein Upgrade.',
errors: {
photoLimit: 'Upload-Limit erreicht. Bitte kontaktiere die Veranstalter für ein Upgrade.',
deviceLimit: 'Dieses Gerät hat das Upload-Limit erreicht. Bitte wende dich an die Veranstalter.',
packageMissing: 'Dieses Event akzeptiert derzeit keine Uploads.',
galleryExpired: 'Die Galerie ist abgelaufen. Uploads sind nicht mehr möglich.',
generic: 'Upload fehlgeschlagen. Bitte versuche es erneut.',
},
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
captureError: 'Foto konnte nicht erstellt werden.',
@@ -652,6 +660,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
},
limitReached: 'Upload limit reached ({used} / {max} photos). Contact the organizers for an upgrade.',
limitUnlimited: 'unlimited',
limitWarning: 'Only {remaining} of {max} photos left. Please contact the organizers for an upgrade.',
errors: {
photoLimit: 'Upload limit reached. Contact the organizers for an upgrade.',
deviceLimit: 'This device reached its upload limit. Please contact the organizers.',
packageMissing: 'This event is not accepting uploads right now.',
galleryExpired: 'The gallery has expired. Uploads are no longer possible.',
generic: 'Upload failed. Please try again.',
},
cameraInactive: 'Camera is not active. {hint}',
cameraInactiveHint: 'Tap "{label}" to get started.',
captureError: 'Photo could not be created.',

View File

@@ -5,7 +5,7 @@ import BottomNav from '../components/BottomNav';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { uploadPhoto } from '../services/photosApi';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { cn } from '@/lib/utils';
import {
@@ -117,6 +117,7 @@ export default function UploadPage() {
const [reviewPhoto, setReviewPhoto] = useState<{ dataUrl: string; file: File } | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
@@ -262,10 +263,29 @@ export default function UploadPage() {
setCanUpload(true);
setUploadError(null);
}
if (pkg?.package?.max_photos) {
const max = Number(pkg.package.max_photos);
const used = Number(pkg.used_photos ?? 0);
const ratio = max > 0 ? used / max : 0;
if (ratio >= 0.8 && ratio < 1) {
const remaining = Math.max(0, max - used);
setUploadWarning(
t('upload.limitWarning')
.replace('{remaining}', `${remaining}`)
.replace('{max}', `${max}`)
);
} else {
setUploadWarning(null);
}
} else {
setUploadWarning(null);
}
} catch (err) {
console.error('Failed to check package limits', err);
setCanUpload(false);
setUploadError(t('upload.limitCheckError'));
setUploadWarning(null);
}
};
@@ -520,7 +540,42 @@ export default function UploadPage() {
navigateAfterUpload(photoId);
} catch (error: unknown) {
console.error('Upload failed', error);
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
const uploadErr = error as UploadError;
setUploadWarning(null);
const meta = uploadErr.meta as Record<string, unknown> | undefined;
switch (uploadErr.code) {
case 'photo_limit_exceeded': {
if (meta && typeof meta.used === 'number' && typeof meta.limit === 'number') {
const limitText = t('upload.limitReached')
.replace('{used}', `${meta.used}`)
.replace('{max}', `${meta.limit}`);
setUploadError(limitText);
} else {
setUploadError(t('upload.errors.photoLimit'));
}
setCanUpload(false);
break;
}
case 'upload_device_limit': {
setUploadError(t('upload.errors.deviceLimit'));
setCanUpload(false);
break;
}
case 'event_package_missing':
case 'event_not_found': {
setUploadError(t('upload.errors.packageMissing'));
setCanUpload(false);
break;
}
case 'gallery_expired': {
setUploadError(t('upload.errors.galleryExpired'));
setCanUpload(false);
break;
}
default: {
setUploadError(getErrorMessage(uploadErr) || t('upload.errors.generic'));
}
}
setMode('review');
} finally {
if (uploadProgressTimerRef.current) {
@@ -773,6 +828,13 @@ export default function UploadPage() {
</div>
<div className="relative z-30 flex flex-col gap-3 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
{uploadWarning && (
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
<AlertDescription className="text-xs">
{uploadWarning}
</AlertDescription>
</Alert>
)}
{uploadError && (
<Alert variant="destructive" className="bg-red-500/10 text-white">
<AlertDescription className="flex items-center gap-2 text-xs">

View File

@@ -1,5 +1,11 @@
import { getDeviceId } from '../lib/device';
export type UploadError = Error & {
code?: string;
status?: number;
meta?: Record<string, unknown>;
};
function getCsrfToken(): string | null {
// Method 1: Meta tag (preferred for SPA)
const metaToken = document.querySelector('meta[name="csrf-token"]');
@@ -56,16 +62,30 @@ export async function likePhoto(id: number): Promise<number> {
});
if (!res.ok) {
const errorText = await res.text();
let payload: any = null;
try {
payload = await res.clone().json();
} catch {}
if (res.status === 419) {
throw new Error('CSRF Token mismatch. This usually means:\n\n' +
'1. The page needs to be refreshed\n' +
'2. Check if <meta name="csrf-token"> is present in HTML source\n' +
'3. API routes might need CSRF exemption in VerifyCsrfToken middleware');
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
error.code = 'csrf_mismatch';
error.status = res.status;
throw error;
}
throw new Error(`Like failed: ${res.status} - ${errorText}`);
const error: UploadError = new Error(
payload?.error?.message ?? `Like failed: ${res.status}`
);
error.code = payload?.error?.code ?? 'like_failed';
error.status = res.status;
if (payload?.error?.meta) {
error.meta = payload.error.meta as Record<string, unknown>;
}
throw error;
}
const json = await res.json();
return json.likes_count ?? json.data?.likes_count ?? 0;
}
@@ -85,15 +105,30 @@ export async function uploadPhoto(eventToken: string, file: File, taskId?: numbe
});
if (!res.ok) {
const errorText = await res.text();
let payload: any = null;
try {
payload = await res.clone().json();
} catch {}
if (res.status === 419) {
throw new Error('CSRF Token mismatch during upload.\n\n' +
'This usually means:\n' +
'1. API routes need CSRF exemption in VerifyCsrfToken middleware\n' +
'2. Check if <meta name="csrf-token"> is present in page source\n' +
'3. The page might need to be refreshed');
const csrfError: UploadError = new Error(
'CSRF token mismatch during upload. Please refresh the page and try again.'
);
csrfError.code = 'csrf_mismatch';
csrfError.status = res.status;
throw csrfError;
}
throw new Error(`Upload failed: ${res.status} - ${errorText}`);
const error: UploadError = new Error(
payload?.error?.message ?? `Upload failed: ${res.status}`
);
error.code = payload?.error?.code ?? 'upload_failed';
error.status = res.status;
if (payload?.error?.meta) {
error.meta = payload.error.meta as Record<string, unknown>;
}
throw error;
}
const json = await res.json();