This commit is contained in:
2026-07-27 20:47:36 +02:00
parent 90dfedf098
commit ab63d4b916
9 changed files with 1199 additions and 547 deletions

View File

@@ -2,18 +2,18 @@
import React, { useState, useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import StyledQRCode from '@/components/generator/StyledQRCode';
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
import {
ModuleShape,
EyeFrameShape,
EyeBallShape,
PRO_MODULE_SHAPES,
BUSINESS_MODULE_SHAPES,
LOW_COVERAGE_SHAPES,
MODULE_SHAPE_LABELS,
EYE_FRAME_LABELS,
EYE_BALL_LABELS,
import StyledQRCode from '@/components/generator/StyledQRCode';
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
import {
ModuleShape,
EyeFrameShape,
EyeBallShape,
PRO_MODULE_SHAPES,
BUSINESS_MODULE_SHAPES,
LOW_COVERAGE_SHAPES,
MODULE_SHAPE_LABELS,
EYE_FRAME_LABELS,
EYE_BALL_LABELS,
} from '@/lib/qr-shapes';
import { toPng } from 'html-to-image';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
@@ -141,13 +141,13 @@ export default function CreatePage() {
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
const [frameType, setFrameType] = useState('none');
const [moduleShape, setModuleShape] = useState<ModuleShape>('square');
const [eyeFrameShape, setEyeFrameShape] = useState<EyeFrameShape>('square');
const [eyeBallShape, setEyeBallShape] = useState<EyeBallShape>('square');
const [gradientMode, setGradientMode] = useState<'none' | 'linear' | 'radial'>('none');
const [gradientTo, setGradientTo] = useState('#7C3AED');
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
const [frameType, setFrameType] = useState('none');
const [moduleShape, setModuleShape] = useState<ModuleShape>('square');
const [eyeFrameShape, setEyeFrameShape] = useState<EyeFrameShape>('square');
const [eyeBallShape, setEyeBallShape] = useState<EyeBallShape>('square');
const [gradientMode, setGradientMode] = useState<'none' | 'linear' | 'radial'>('none');
const [gradientTo, setGradientTo] = useState('#7C3AED');
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
const [presetName, setPresetName] = useState('');
// Upgrade modal. Replaces the old redirect to /pricing, which destroyed the
@@ -503,75 +503,75 @@ export default function CreatePage() {
);
};
// The logo belongs in the preset. For an agency, "client A looks the same on
// all 500 codes" is mostly about the mark in the middle - a preset that
// carries the colours but drops the logo solves the smaller half of the job.
const currentDesign = () => ({
foregroundColor,
backgroundColor,
moduleShape,
eyeFrameShape,
eyeBallShape,
gradientMode,
gradientTo,
frameType,
logoUrl: canUseLogo ? logoUrl : '',
logoSize,
});
const applyDesign = (style: any) => {
if (!style) return;
if (style.foregroundColor) setForegroundColor(style.foregroundColor);
if (style.backgroundColor) setBackgroundColor(style.backgroundColor);
if (style.moduleShape) setModuleShape(style.moduleShape);
if (style.eyeFrameShape) setEyeFrameShape(style.eyeFrameShape);
if (style.eyeBallShape) setEyeBallShape(style.eyeBallShape);
if (style.gradientMode) setGradientMode(style.gradientMode);
if (style.gradientTo) setGradientTo(style.gradientTo);
if (style.frameType) setFrameType(style.frameType);
if (typeof style.logoUrl === 'string' && canUseLogo) setLogoUrl(style.logoUrl);
if (style.logoSize) setLogoSize(style.logoSize);
};
const loadPresets = async () => {
try {
const res = await fetch('/api/design-presets');
if (res.ok) setPresets(await res.json());
} catch {
// presets are a convenience, never block the page on them
}
};
useEffect(() => {
if (canUseFullDesign) void loadPresets();
}, [canUseFullDesign]);
const savePreset = async () => {
const name = presetName.trim();
if (!name) {
showToast('Give the preset a name first.', 'error');
return;
}
const res = await fetchWithCsrf('/api/design-presets', {
method: 'POST',
body: JSON.stringify({ name, style: currentDesign() }),
});
if (res.ok) {
setPresetName('');
await loadPresets();
trackEvent('design_preset_saved', { plan: userPlan });
showToast(`Preset "${name}" saved.`, 'success');
} else {
const err = await res.json().catch(() => null);
showToast(err?.message || 'Could not save the preset.', 'error');
}
};
const deletePreset = async (id: string) => {
const res = await fetchWithCsrf(`/api/design-presets?id=${id}`, { method: 'DELETE' });
if (res.ok) await loadPresets();
};
// The logo belongs in the preset. For an agency, "client A looks the same on
// all 500 codes" is mostly about the mark in the middle - a preset that
// carries the colours but drops the logo solves the smaller half of the job.
const currentDesign = () => ({
foregroundColor,
backgroundColor,
moduleShape,
eyeFrameShape,
eyeBallShape,
gradientMode,
gradientTo,
frameType,
logoUrl: canUseLogo ? logoUrl : '',
logoSize,
});
const applyDesign = (style: any) => {
if (!style) return;
if (style.foregroundColor) setForegroundColor(style.foregroundColor);
if (style.backgroundColor) setBackgroundColor(style.backgroundColor);
if (style.moduleShape) setModuleShape(style.moduleShape);
if (style.eyeFrameShape) setEyeFrameShape(style.eyeFrameShape);
if (style.eyeBallShape) setEyeBallShape(style.eyeBallShape);
if (style.gradientMode) setGradientMode(style.gradientMode);
if (style.gradientTo) setGradientTo(style.gradientTo);
if (style.frameType) setFrameType(style.frameType);
if (typeof style.logoUrl === 'string' && canUseLogo) setLogoUrl(style.logoUrl);
if (style.logoSize) setLogoSize(style.logoSize);
};
const loadPresets = async () => {
try {
const res = await fetch('/api/design-presets');
if (res.ok) setPresets(await res.json());
} catch {
// presets are a convenience, never block the page on them
}
};
useEffect(() => {
if (canUseFullDesign) void loadPresets();
}, [canUseFullDesign]);
const savePreset = async () => {
const name = presetName.trim();
if (!name) {
showToast('Give the preset a name first.', 'error');
return;
}
const res = await fetchWithCsrf('/api/design-presets', {
method: 'POST',
body: JSON.stringify({ name, style: currentDesign() }),
});
if (res.ok) {
setPresetName('');
await loadPresets();
trackEvent('design_preset_saved', { plan: userPlan });
showToast(`Preset "${name}" saved.`, 'success');
} else {
const err = await res.json().catch(() => null);
showToast(err?.message || 'Could not save the preset.', 'error');
}
};
const deletePreset = async (id: string) => {
const res = await fetchWithCsrf(`/api/design-presets?id=${id}`, { method: 'DELETE' });
if (res.ok) await loadPresets();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -1050,7 +1050,7 @@ export default function CreatePage() {
};
return (
<div className="max-w-6xl mx-auto">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
<p className="text-gray-600 mt-2">{t('create.subtitle')}</p>
@@ -1165,220 +1165,220 @@ export default function CreatePage() {
</CardHeader>
<CardContent className="space-y-6">
{/* Module shape. Colors are free; shapes are the Pro driver that
replaced them. Locked options stay clickable so the preview
shows what is being bought before anyone pays for it. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Module shape</label>
{!canUseShapes && <Badge variant="info">Pro</Badge>}
</div>
<div className="grid grid-cols-4 gap-2">
{([...PRO_MODULE_SHAPES, ...BUSINESS_MODULE_SHAPES] as ModuleShape[]).map((shape) => {
const isBusinessOnly = BUSINESS_MODULE_SHAPES.includes(shape);
const allowed = shape === 'square'
|| (isBusinessOnly ? canUseFullDesign : canUseShapes);
return (
<button
key={shape}
type="button"
onClick={() => {
setModuleShape(shape);
if (!allowed) {
trackEvent('upgrade_prompt_shown', {
reason: 'shapes',
shape,
plan: userPlan,
});
setUpgradeReason('shapes');
setUpgradeOpen(true);
}
}}
className={cn(
'rounded-lg border p-2 text-xs transition-colors',
moduleShape === shape
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
!allowed && 'opacity-60'
)}
>
<span className="block truncate">{MODULE_SHAPE_LABELS[shape]}</span>
{!allowed && (
<span className="mt-0.5 block text-[10px] text-gray-400">
{isBusinessOnly ? 'Business' : 'Pro'}
</span>
)}
</button>
);
})}
</div>
</div>
{/* Eye styles. Only the combinations that survived decoding are
offered - see the note in lib/qr-shapes.ts. */}
<div className="grid grid-cols-2 gap-4">
<Select
label="Eye frame"
value={eyeFrameShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeFrameShape(e.target.value as EyeFrameShape);
}}
options={Object.entries(EYE_FRAME_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
label="Eye centre"
value={eyeBallShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeBallShape(e.target.value as EyeBallShape);
}}
options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
{/* Gradient. Business only - the renderer takes it as a prop, so
this is purely a gating and input concern. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Gradient</label>
{!canUseFullDesign && <Badge variant="info">Business</Badge>}
</div>
<div className="grid grid-cols-3 gap-2">
{(['none', 'linear', 'radial'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => {
if (mode !== 'none' && !canUseFullDesign) {
trackEvent('upgrade_prompt_shown', { reason: 'shapes', feature: 'gradient', plan: userPlan });
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setGradientMode(mode);
}}
className={cn(
'rounded-lg border p-2 text-xs capitalize transition-colors',
gradientMode === mode
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
mode !== 'none' && !canUseFullDesign && 'opacity-60'
)}
>
{mode === 'none' ? 'Solid' : mode}
</button>
))}
</div>
{gradientMode !== 'none' && (
<div className="mt-3 flex items-center gap-2">
<label className="text-sm text-gray-700">Second colour</label>
<input
type="color"
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="h-10 w-12 rounded border border-gray-300"
/>
<Input
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="flex-1"
/>
</div>
)}
</div>
{/* Scannability. Says what was changed and why, rather than
silently raising the error correction behind the user. */}
{(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-sm text-amber-900">
{logoUrl
? 'Error correction is set to H because this code carries a logo.'
: `"${MODULE_SHAPE_LABELS[moduleShape]}" fills less of each module, so error correction has been raised.`}
</p>
<p className="mt-1 text-sm text-amber-800">
Print it at 2 x 2 cm or larger, and scan it once with your own
phone before you send it to the printer.
</p>
</div>
)}
{/* Saved presets. Business only. Repeatability is the actual
product here - the star shape is not what an agency buys. */}
{canUseFullDesign && (
<div className="rounded-lg border border-gray-200 p-3">
<label className="mb-2 block text-sm font-medium text-gray-700">
Design presets
</label>
{presets.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{presets.map((preset) => (
<span
key={preset.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 py-1 pl-3 pr-1 text-xs"
>
<button
type="button"
onClick={() => applyDesign(preset.style)}
className="text-gray-700 hover:text-primary-700"
>
{preset.name}
</button>
<button
type="button"
onClick={() => deletePreset(preset.id)}
className="px-1 text-gray-400 hover:text-red-600"
aria-label={`Delete preset ${preset.name}`}
>
&times;
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="Client A"
className="flex-1"
/>
<Button type="button" variant="outline" size="sm" onClick={savePreset}>
Save current design
</Button>
</div>
<p className="mt-2 text-xs text-gray-500">
Saving under an existing name overwrites it.
</p>
</div>
)}
{/* Module shape. Colors are free; shapes are the Pro driver that
replaced them. Locked options stay clickable so the preview
shows what is being bought before anyone pays for it. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Module shape</label>
{!canUseShapes && <Badge variant="info">Pro</Badge>}
</div>
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
{([...PRO_MODULE_SHAPES, ...BUSINESS_MODULE_SHAPES] as ModuleShape[]).map((shape) => {
const isBusinessOnly = BUSINESS_MODULE_SHAPES.includes(shape);
const allowed = shape === 'square'
|| (isBusinessOnly ? canUseFullDesign : canUseShapes);
return (
<button
key={shape}
type="button"
onClick={() => {
setModuleShape(shape);
if (!allowed) {
trackEvent('upgrade_prompt_shown', {
reason: 'shapes',
shape,
plan: userPlan,
});
setUpgradeReason(isBusinessOnly ? 'business-shapes' : 'shapes');
setUpgradeOpen(true);
}
}}
className={cn(
'rounded-lg border p-2 text-xs transition-colors lg:p-3 lg:text-sm',
moduleShape === shape
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
!allowed && 'opacity-60'
)}
>
<span className="block truncate">{MODULE_SHAPE_LABELS[shape]}</span>
{!allowed && (
<span className="mt-0.5 block text-[10px] text-gray-400">
{isBusinessOnly ? 'Business' : 'Pro'}
</span>
)}
</button>
);
})}
</div>
</div>
{/* Eye styles. Only the combinations that survived decoding are
offered - see the note in lib/qr-shapes.ts. */}
<div className="grid grid-cols-2 gap-4">
<Select
label="Eye frame"
value={eyeFrameShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeFrameShape(e.target.value as EyeFrameShape);
}}
options={Object.entries(EYE_FRAME_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
<Select
label="Eye centre"
value={eyeBallShape}
onChange={(e) => {
if (!canUseShapes) {
setUpgradeReason('shapes');
setUpgradeOpen(true);
return;
}
setEyeBallShape(e.target.value as EyeBallShape);
}}
options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
{/* Gradient. Business only - the renderer takes it as a prop, so
this is purely a gating and input concern. */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">Gradient</label>
{!canUseFullDesign && <Badge variant="info">Business</Badge>}
</div>
<div className="grid grid-cols-3 gap-2">
{(['none', 'linear', 'radial'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => {
if (mode !== 'none' && !canUseFullDesign) {
trackEvent('upgrade_prompt_shown', { reason: 'business-shapes', feature: 'gradient', plan: userPlan });
setUpgradeReason('business-shapes');
setUpgradeOpen(true);
return;
}
setGradientMode(mode);
}}
className={cn(
'rounded-lg border p-2 text-xs capitalize transition-colors',
gradientMode === mode
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300',
mode !== 'none' && !canUseFullDesign && 'opacity-60'
)}
>
{mode === 'none' ? 'Solid' : mode}
</button>
))}
</div>
{gradientMode !== 'none' && (
<div className="mt-3 flex items-center gap-2">
<label className="text-sm text-gray-700">Second colour</label>
<input
type="color"
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="h-10 w-12 rounded border border-gray-300"
/>
<Input
value={gradientTo}
onChange={(e) => setGradientTo(e.target.value)}
className="flex-1"
/>
</div>
)}
</div>
{/* Scannability. Says what was changed and why, rather than
silently raising the error correction behind the user. */}
{(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-sm text-amber-900">
{logoUrl
? 'Error correction is set to H because this code carries a logo.'
: `"${MODULE_SHAPE_LABELS[moduleShape]}" fills less of each module, so error correction has been raised.`}
</p>
<p className="mt-1 text-sm text-amber-800">
Print it at 2 x 2 cm or larger, and scan it once with your own
phone before you send it to the printer.
</p>
</div>
)}
{/* Saved presets. Business only. Repeatability is the actual
product here - the star shape is not what an agency buys. */}
{canUseFullDesign && (
<div className="rounded-lg border border-gray-200 p-3">
<label className="mb-2 block text-sm font-medium text-gray-700">
Design presets
</label>
{presets.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{presets.map((preset) => (
<span
key={preset.id}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 py-1 pl-3 pr-1 text-xs"
>
<button
type="button"
onClick={() => applyDesign(preset.style)}
className="text-gray-700 hover:text-primary-700"
>
{preset.name}
</button>
<button
type="button"
onClick={() => deletePreset(preset.id)}
className="px-1 text-gray-400 hover:text-red-600"
aria-label={`Delete preset ${preset.name}`}
>
&times;
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="Client A"
className="flex-1"
/>
<Button type="button" variant="outline" size="sm" onClick={savePreset}>
Save current design
</Button>
</div>
<p className="mt-2 text-xs text-gray-500">
Saving under an existing name overwrites it.
</p>
</div>
)}
{/* Frame Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Frame</label>
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
{frameOptions.map((frame: { id: string; label: string }) => (
<button
key={frame.id}
type="button"
onClick={() => setFrameType(frame.id)}
className={cn(
"py-2 px-3 rounded-lg text-sm font-medium transition-all border",
"py-2 px-3 rounded-lg text-sm font-medium transition-all border lg:py-3",
frameType === frame.id
? "bg-slate-900 text-white border-slate-900"
: "bg-gray-50 text-gray-600 border-gray-200 hover:border-gray-300"
@@ -1573,7 +1573,7 @@ export default function CreatePage() {
{/* WRAPPER FOR REF AND FRAME */}
<div
ref={qrRef}
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4"
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4 lg:p-6"
style={{
minHeight: '220px',
}}
@@ -1679,6 +1679,7 @@ export default function CreatePage() {
<UpgradeModal
open={upgradeOpen}
reason={upgradeReason}
plan={userPlan}
currentCount={limitInfo?.current}
limit={limitInfo?.limit}
activeCodes={activeCodes}