fix V2
This commit is contained in:
@@ -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}`}
|
||||
>
|
||||
×
|
||||
</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}`}
|
||||
>
|
||||
×
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user