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

@@ -9,8 +9,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select';
import { QRCodeSVG } from 'qrcode.react';
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
import {
cellToString,
detectBulkContent,
presetStyleToQrStyle,
type DetectedContent,
} from '@/lib/bulk-content';
import { showToast } from '@/components/ui/Toast';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
@@ -28,6 +33,8 @@ interface GeneratedQR {
svg: string;
slug?: string;
redirectUrl?: string;
/** Kept so the save step stores the same code that was previewed. */
detected?: DetectedContent;
}
export default function BulkCreationPage() {
@@ -39,16 +46,24 @@ export default function BulkCreationPage() {
const [loading, setLoading] = useState(false);
const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
const [userPlan, setUserPlan] = useState<string>('FREE');
// Until the plan has actually come back from the server we know nothing.
// Defaulting to FREE and rendering the paywall meant every Business user saw
// "upgrade to Business" flash before their own page appeared.
const [planLoaded, setPlanLoaded] = useState(false);
const [isDynamic, setIsDynamic] = useState(false);
const [remainingDynamic, setRemainingDynamic] = useState(0);
// Rows the API refused. Previously these vanished silently and the success
// toast reported a smaller number with no explanation - the worst kind of
// failure for someone who is about to send a batch to print.
const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]);
// A saved design applied to the whole batch. This is the reason presets exist:
// 500 codes that all look like the same client, from one upload.
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]);
// A saved design applied to the whole batch. This is the reason presets exist:
// 500 codes that all look like the same client, from one upload.
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
const [presetId, setPresetId] = useState('');
// Whether this batch is already in the dashboard. Dynamic codes are written
// during generation; static ones only when the button is pressed. Without
// tracking it, pressing Save twice created the whole batch twice.
const [savedToDashboard, setSavedToDashboard] = useState(false);
// Reload the remaining dynamic quota from the server. Counting down locally
// drifts as soon as anything is created in another tab, which is how rows
@@ -58,7 +73,7 @@ export default function BulkCreationPage() {
const statsRes = await fetch('/api/user/stats');
if (statsRes.ok) {
const stats = await statsRes.json();
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0));
setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
}
} catch (error) {
console.error('Error refreshing quota:', error);
@@ -74,6 +89,11 @@ export default function BulkCreationPage() {
const activeStyle = () => presets.find((p) => p.id === presetId)?.style ?? null;
// Titles are capped at 100 characters server-side. A single long cell used to
// reject the whole upload with a validation error that named no row.
const safeTitle = (value: unknown) =>
(cellToString(value) || 'Untitled').slice(0, 100);
// Check user plan and dynamic quota on mount
React.useEffect(() => {
const checkPlan = async () => {
@@ -88,10 +108,12 @@ export default function BulkCreationPage() {
}
if (statsRes.ok) {
const stats = await statsRes.json();
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0));
setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
}
} catch (error) {
console.error('Error checking plan:', error);
} finally {
setPlanLoaded(true);
}
};
checkPlan();
@@ -193,44 +215,33 @@ export default function BulkCreationPage() {
try {
const qrCodes: GeneratedQR[] = [];
const style = activeStyle();
// Generate all QR codes client-side (Static QR Codes)
for (const row of data) {
const title = row[mapping.title as keyof typeof row] || 'Untitled';
const content = row[mapping.content as keyof typeof row] || 'https://example.com';
const title = safeTitle(row[mapping.title as keyof typeof row]);
const rawContent = row[mapping.content as keyof typeof row];
// Create a temporary div to render QR code
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
// The cell decides the code type. Encoding a phone number as a URL is
// how a batch of "static QR codes" ended up scanning as broken links.
const detected = detectBulkContent(rawContent);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '300');
svg.setAttribute('height', '300');
tempDiv.appendChild(svg);
// Use qrcode library to generate SVG
const QRCode = require('qrcode');
const style = activeStyle();
const qrSvg = style
? renderStyledQRSvg(String(content), style, 300)
: await QRCode.toString(content, {
type: 'svg',
width: 300,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
});
// One renderer for both plain and styled codes. The plain path used to
// go through QRCode.toString at error correction M while the styled
// path used H, so the same row produced two different codes depending
// on whether a preset was selected.
const qrSvg = renderStyledQRSvg(detected.qrValue, style, 300);
qrCodes.push({
title: String(title),
content: String(content), // Store the original URL
title,
content: cellToString(rawContent),
svg: qrSvg,
detected,
});
document.body.removeChild(tempDiv);
}
setGeneratedQRs(qrCodes);
setFailedRows([]);
setSavedToDashboard(false);
setStep('complete');
showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success');
} catch (error) {
@@ -243,73 +254,92 @@ export default function BulkCreationPage() {
const generateDynamicQRCodes = async () => {
setLoading(true);
const toProcess = remainingDynamic > 0 ? data.slice(0, remainingDynamic) : [];
if (toProcess.length === 0) {
showToast('No dynamic QR codes left on your plan. Free a slot or upgrade to continue.', 'error');
setLoading(false);
return;
}
if (data.length > remainingDynamic) {
showToast(
`Only ${remainingDynamic} dynamic codes left. The first ${remainingDynamic} rows will be processed - the rest are listed after the run.`,
'warning'
);
}
try {
const QRCode = require('qrcode');
const results: GeneratedQR[] = [];
const failures: { row: number; title: string; reason: string }[] = [];
const items: { title: string; contentType: 'URL'; content: { url: string } }[] = [];
// Which upload row each accepted item came from, so the failure list the
// server sends back can be mapped to the row the user actually sees.
const rowOfItem: number[] = [];
// Rows the plan could not cover are counted from the start, so the summary
// reflects the whole upload and not just the slice we attempted.
data.slice(toProcess.length).forEach((row, i) => {
failures.push({
row: toProcess.length + i + 1,
title: String(row[mapping.title as keyof typeof row] || 'Untitled'),
reason: 'No dynamic code slots left on your plan',
});
});
data.forEach((row: any, i) => {
const title = safeTitle(row[mapping.title as keyof typeof row]);
const detected = detectBulkContent(row[mapping.content as keyof typeof row]);
for (let i = 0; i < toProcess.length; i++) {
const row = toProcess[i];
const title = String(row[mapping.title as keyof typeof row] || 'Untitled');
const url = String(row[mapping.content as keyof typeof row] || 'https://example.com');
const res = await fetchWithCsrf('/api/qrs', {
method: 'POST',
body: JSON.stringify({
title,
contentType: 'URL',
content: { url },
isStatic: false,
}),
});
if (res.ok) {
const qr = await res.json();
const redirectUrl = `${window.location.origin}/r/${qr.slug}`;
const style = activeStyle();
const svg = style
? renderStyledQRSvg(redirectUrl, style, 300)
: await QRCode.toString(redirectUrl, { type: 'svg', width: 300, margin: 2 });
results.push({ title, content: url, svg, slug: qr.slug, redirectUrl });
} else {
const err = await res.json().catch(() => null);
// A dynamic code is a redirect, so anything that is not a link cannot
// become one. Saying so up front beats storing a code that leads nowhere.
if (detected.contentType !== 'URL') {
failures.push({
row: i + 1,
title,
reason: err?.error === 'Limit reached'
? 'No dynamic code slots left on your plan'
: err?.error || `Request failed (${res.status})`,
reason: 'Dynamic codes need a web address in the content column',
});
return;
}
items.push({ title, contentType: 'URL', content: { url: detected.content.url } });
rowOfItem.push(i + 1);
});
if (items.length === 0) {
setGeneratedQRs([]);
setFailedRows(failures);
setStep('complete');
showToast('None of the rows could be turned into a dynamic QR code.', 'error');
return;
}
if (items.length > remainingDynamic) {
showToast(
`Only ${remainingDynamic} dynamic slots left. The rest are listed after the run.`,
'warning'
);
}
// One request for the whole batch. Row-by-row POSTs ran straight into the
// per-minute create limit, so most of a large upload silently 429'd.
const res = await fetchWithCsrf('/api/qrs/bulk', {
method: 'POST',
body: JSON.stringify({
items,
isStatic: false,
style: presetStyleToQrStyle(activeStyle()),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
showToast(err?.message || err?.error || 'Could not create the QR codes.', 'error');
return;
}
const { created, failed } = (await res.json()) as {
created: { row: number; title: string; slug: string }[];
failed: { row: number; title: string; reason: string }[];
};
failed.forEach((f) => {
failures.push({ ...f, row: rowOfItem[f.row - 1] ?? f.row });
});
const style = activeStyle();
const results: GeneratedQR[] = created.map((qr) => {
const redirectUrl = `${window.location.origin}/r/${qr.slug}`;
return {
title: qr.title,
content: items[qr.row - 1]?.content.url ?? '',
svg: renderStyledQRSvg(redirectUrl, style, 300),
slug: qr.slug,
redirectUrl,
};
});
failures.sort((a, b) => a.row - b.row);
setGeneratedQRs(results);
setFailedRows(failures);
// Dynamic codes exist in the dashboard the moment they are generated.
setSavedToDashboard(true);
await refreshQuota();
setStep('complete');
@@ -372,37 +402,63 @@ export default function BulkCreationPage() {
};
const saveQRCodesToDatabase = async () => {
if (isDynamic) return; // dynamic codes are already saved during generation
// Dynamic codes are written during generation, and a second press would
// duplicate a static batch. Either way there is nothing left to save.
if (savedToDashboard) return;
if (generatedQRs.length === 0) {
showToast('There are no QR codes to save.', 'error');
return;
}
setLoading(true);
try {
const qrCodesToSave = generatedQRs.map((qr) => ({
title: qr.title,
isStatic: true, // This tells the API it's a static QR code
contentType: 'URL',
content: { url: qr.content }, // Content needs to be an object with url property
status: 'ACTIVE',
}));
const items = generatedQRs.map((qr) => {
const detected = qr.detected ?? detectBulkContent(qr.content);
return {
title: qr.title,
contentType: detected.contentType,
content: detected.content,
};
});
// Save each QR code to the database
const savePromises = qrCodesToSave.map((qr) =>
fetchWithCsrf('/api/qrs', {
method: 'POST',
body: JSON.stringify(qr),
})
);
// The design goes with them. Without this the batch was previewed in the
// user's own branding and then stored as plain black and white - the code
// on screen and the code in the dashboard were not the same picture.
const res = await fetchWithCsrf('/api/qrs/bulk', {
method: 'POST',
body: JSON.stringify({
items,
isStatic: true,
style: presetStyleToQrStyle(activeStyle()),
}),
});
const results = await Promise.all(savePromises);
const failedCount = results.filter((r) => !r.ok).length;
if (!res.ok) {
const err = await res.json().catch(() => null);
showToast(err?.message || err?.error || 'Failed to save QR codes', 'error');
return;
}
if (failedCount === 0) {
showToast(`Successfully saved ${qrCodesToSave.length} QR codes!`, 'success');
// Redirect to dashboard after 1 second
const { created, failed } = (await res.json()) as {
created: unknown[];
failed: { row: number; title: string; reason: string }[];
};
setSavedToDashboard(true);
if (failed.length === 0) {
showToast(`Successfully saved ${created.length} QR codes!`, 'success');
setTimeout(() => {
window.location.href = '/dashboard';
}, 1000);
} else {
showToast(`Saved ${qrCodesToSave.length - failedCount} QR codes, ${failedCount} failed`, 'warning');
// Named rows, not a count. "12 failed" out of a print batch is not
// something anyone can act on.
setFailedRows(failed);
showToast(
`Saved ${created.length} QR codes. ${failed.length} could not be saved - see the list below.`,
'warning'
);
}
} catch (error) {
console.error('Error saving QR codes:', error);
@@ -437,6 +493,21 @@ export default function BulkCreationPage() {
URL.revokeObjectURL(url);
};
// Nothing is known about the plan until the request comes back. Rendering the
// paywall in the meantime told paying customers they had not paid.
if (!planLoaded) {
return (
<div className="max-w-6xl mx-auto animate-pulse">
<div className="mb-8 space-y-3">
<div className="h-9 w-64 rounded-lg bg-gray-200" />
<div className="h-5 w-96 rounded bg-gray-100" />
</div>
<div className="h-24 rounded-xl bg-gray-100" />
<div className="mt-6 h-64 rounded-xl bg-gray-100" />
</div>
);
}
// Show upgrade prompt if not Business or Enterprise plan
if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') {
return (
@@ -473,26 +544,26 @@ export default function BulkCreationPage() {
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
{/* Apply a saved design to the whole batch. */}
{presets.length > 0 && (
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 p-4">
<label className="mb-2 block text-sm font-medium text-gray-700">
Design preset
</label>
<Select
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
options={[
{ value: '', label: 'Plain black and white' },
...presets.map((p) => ({ value: p.id, label: p.name })),
]}
/>
<p className="mt-2 text-xs text-gray-500">
Applied to every code in this upload, so the whole batch matches.
</p>
</div>
)}
{/* Apply a saved design to the whole batch. */}
{presets.length > 0 && (
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 p-4">
<label className="mb-2 block text-sm font-medium text-gray-700">
Design preset
</label>
<Select
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
options={[
{ value: '', label: 'Plain black and white' },
...presets.map((p) => ({ value: p.id, label: p.name })),
]}
/>
<p className="mt-2 text-xs text-gray-500">
Applied to every code in this upload, so the whole batch matches.
</p>
</div>
)}
{/* Static / Dynamic Toggle */}
<div className="mt-4 flex items-center gap-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
<span className="text-sm font-medium text-gray-700">QR Code Type:</span>
@@ -551,11 +622,25 @@ export default function BulkCreationPage() {
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{/* This banner used to say "static only", which stopped being true
when the dynamic option was added. It now describes whichever
mode is actually selected above. */}
<div>
<h3 className="font-semibold text-blue-900 mb-1">Static QR Codes Only</h3>
<h3 className="font-semibold text-blue-900 mb-1">
{isDynamic ? 'Dynamic QR Codes' : 'Static QR Codes'}
</h3>
<p className="text-sm text-blue-800">
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
{isDynamic ? (
<>
Each code becomes a <strong>trackable short link</strong> you can re-point later.
The content column must hold a web address, and each code uses one dynamic slot.
</>
) : (
<>
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
</>
)}
</p>
</div>
</div>
@@ -827,22 +912,37 @@ export default function BulkCreationPage() {
</tr>
</thead>
<tbody>
{data.slice(0, 5).map((row: any, index) => (
<tr key={index} className="border-b">
<td className="py-3 px-4">
<QRCodeSVG
value={row[mapping.content] || 'https://example.com'}
size={40}
/>
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{row[mapping.title] || 'Untitled'}
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{(row[mapping.content] || '').substring(0, 50)}...
</td>
</tr>
))}
{data.slice(0, 5).map((row: any, index) => {
// Same detection and same renderer as the real run, so this
// table is a preview rather than a lookalike.
const detected = detectBulkContent(row[mapping.content]);
const raw = cellToString(row[mapping.content]);
return (
<tr key={index} className="border-b">
<td className="py-3 px-4">
<div
className="h-10 w-10"
dangerouslySetInnerHTML={{
__html: renderStyledQRSvg(
detected.qrValue || 'https://example.com',
activeStyle(),
40
),
}}
/>
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{cellToString(row[mapping.title]) || 'Untitled'}
<span className="ml-2 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
{detected.contentType}
</span>
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{raw.length > 50 ? `${raw.substring(0, 50)}...` : raw}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -862,7 +962,7 @@ export default function BulkCreationPage() {
loading={loading}
>
{isDynamic
? `Generate ${Math.min(data.length, remainingDynamic)} Dynamic QR Codes`
? `Generate ${Math.max(0, Math.min(data.length, remainingDynamic))} Dynamic QR Codes`
: `Generate ${data.length} Static QR Codes`}
</Button>
</div>
@@ -887,7 +987,9 @@ export default function BulkCreationPage() {
<p className="text-gray-600 mb-8">
{failedRows.length > 0
? 'The rows below could not be added. Nothing was silently dropped - here is exactly what is missing.'
: `${generatedQRs.length} ${isDynamic ? 'dynamic' : 'static'} QR codes, ready to download.`}
: savedToDashboard
? `${generatedQRs.length} ${isDynamic ? 'dynamic' : 'static'} QR codes, saved to your dashboard and ready to download.`
: `${generatedQRs.length} static QR codes, ready to download. Save them to keep them in your dashboard.`}
</p>
{failedRows.length > 0 && (
@@ -959,6 +1061,10 @@ export default function BulkCreationPage() {
setData([]);
setMapping({});
setGeneratedQRs([]);
// Left over from the previous run, the failure list reappeared
// on top of the next upload as if it belonged to it.
setFailedRows([]);
setSavedToDashboard(false);
}}>
Create More
</Button>
@@ -968,8 +1074,21 @@ export default function BulkCreationPage() {
</svg>
Download All as ZIP
</Button>
{!isDynamic && (
<Button onClick={saveQRCodesToDatabase} loading={loading}>
{/* There is always an action here. The Save button used to be
hidden entirely for dynamic batches, which left the final
screen with no way forward at all - the codes were in the
dashboard, but nothing on screen said so. */}
{savedToDashboard ? (
<Link href="/dashboard">
<Button>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Saved - View in Dashboard
</Button>
</Link>
) : (
<Button onClick={saveQRCodesToDatabase} loading={loading} disabled={generatedQRs.length === 0}>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>

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}

View File

@@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { showToast } from '@/components/ui/Toast';
import { BillingToggle } from '@/components/ui/BillingToggle';
import { trackEvent } from '@/components/PostHogProvider';
import { Check, Loader2 } from 'lucide-react';
import {
@@ -15,6 +16,7 @@ import {
} from '@/lib/plans';
type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
type BillingInterval = 'month' | 'year';
/**
* In-app upgrade page.
@@ -31,7 +33,8 @@ type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
const PLANS: {
key: PlanKey;
name: string;
price: string;
priceMonth: string;
priceYear: string;
period: string;
caption: string;
features: string[];
@@ -40,7 +43,8 @@ const PLANS: {
{
key: 'FREE',
name: 'Free',
price: '€0',
priceMonth: '€0',
priceYear: '€0',
period: 'forever',
caption: 'Enough to prove the idea on one or two placements.',
features: [
@@ -54,7 +58,8 @@ const PLANS: {
{
key: 'PRO',
name: 'Pro',
price: '€9',
priceMonth: '€9',
priceYear: '€90',
period: 'per month',
popular: true,
caption: 'When one campaign is no longer the only campaign.',
@@ -69,13 +74,14 @@ const PLANS: {
{
key: 'BUSINESS',
name: 'Business',
price: '€29',
priceMonth: '€29',
priceYear: '€290',
period: 'per month',
caption: 'When codes are produced in batches, not one at a time.',
features: [
`${BUSINESS_DYNAMIC_QR_LIMIT} dynamic QR codes`,
'Bulk creation: 1,000 static or 500 dynamic per upload',
'Full designer: 11 module shapes and colour gradients',
'Full designer: 11 module shapes and colour gradients',
'Saved design presets, applied to a whole bulk upload',
'Priority email support',
'Everything in Pro',
@@ -94,6 +100,8 @@ const REASON_HEADLINES: Record<string, string> = {
export default function UpgradePage() {
const searchParams = useSearchParams();
const [currentPlan, setCurrentPlan] = useState<PlanKey>('FREE');
const [currentInterval, setCurrentInterval] = useState<BillingInterval | null>(null);
const [billingPeriod, setBillingPeriod] = useState<BillingInterval>('month');
const [loadingPlan, setLoadingPlan] = useState<PlanKey | null>(null);
const reason = searchParams.get('reason');
@@ -102,7 +110,10 @@ export default function UpgradePage() {
useEffect(() => {
fetch('/api/user/plan')
.then((r) => (r.ok ? r.json() : null))
.then((d) => d?.plan && setCurrentPlan(d.plan))
.then((d) => {
if (d?.plan) setCurrentPlan(d.plan);
if (d?.interval) setCurrentInterval(d.interval);
})
.catch(() => {});
}, []);
@@ -115,7 +126,12 @@ export default function UpgradePage() {
const handleUpgrade = async (plan: PlanKey) => {
if (plan === 'FREE') return;
setLoadingPlan(plan);
trackEvent('upgrade_clicked', { plan, source: 'in_app_upgrade', reason });
trackEvent('upgrade_clicked', {
plan,
billing_interval: billingPeriod,
source: 'in_app_upgrade',
reason,
});
try {
const res = await fetch('/api/stripe/create-checkout-session', {
@@ -123,7 +139,7 @@ export default function UpgradePage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plan,
billingInterval: 'month',
billingInterval: billingPeriod,
returnPath: returnTo && returnTo.startsWith('/') ? returnTo : '/dashboard',
}),
});
@@ -141,6 +157,34 @@ export default function UpgradePage() {
}
};
const handleDowngrade = async () => {
const confirmed = window.confirm(
'Are you sure you want to cancel your paid plan? You will keep premium features until the end of your current billing period.'
);
if (!confirmed) return;
setLoadingPlan('FREE');
trackEvent('downgrade_clicked', { source: 'in_app_upgrade', current_plan: currentPlan });
try {
const res = await fetch('/api/stripe/cancel-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Failed to cancel subscription.');
}
showToast('Subscription will end at the end of your current billing period.', 'success');
setTimeout(() => window.location.reload(), 1500);
} catch (err: any) {
showToast(err?.message || 'Could not downgrade. Please try again.', 'error');
setLoadingPlan(null);
}
};
return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
<div className="mb-10 max-w-2xl">
@@ -156,9 +200,25 @@ export default function UpgradePage() {
</p>
</div>
<div className="mb-8 flex justify-center">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div>
<div className="grid gap-6 lg:grid-cols-3">
{PLANS.map((plan) => {
const isCurrent = plan.key === currentPlan;
const isCurrent =
plan.key === currentPlan &&
(plan.key === 'FREE' || currentInterval === null || currentInterval === billingPeriod);
const hasPlanDifferentInterval =
plan.key !== 'FREE' &&
plan.key === currentPlan &&
currentInterval !== null &&
currentInterval !== billingPeriod;
const price = billingPeriod === 'month' ? plan.priceMonth : plan.priceYear;
const period =
plan.key === 'FREE' ? plan.period : billingPeriod === 'month' ? 'per month' : 'per year';
const isDowngrade = plan.key === 'FREE' && currentPlan !== 'FREE';
return (
<Card
key={plan.key}
@@ -172,9 +232,14 @@ export default function UpgradePage() {
</div>
<div className="mb-1 flex items-baseline gap-2">
<span className="text-4xl font-bold text-slate-900">{plan.price}</span>
<span className="text-sm text-slate-500">{plan.period}</span>
<span className="text-4xl font-bold text-slate-900">{price}</span>
<span className="text-sm text-slate-500">{period}</span>
</div>
{plan.key !== 'FREE' && billingPeriod === 'year' && (
<Badge variant="success" className="mb-4 w-fit">
Save 16%
</Badge>
)}
<p className="mb-6 text-sm text-slate-600">{plan.caption}</p>
<ul className="mb-8 space-y-3">
@@ -191,6 +256,21 @@ export default function UpgradePage() {
<Button variant="outline" className="w-full" disabled>
Current plan
</Button>
) : isDowngrade ? (
<Button
variant="outline"
className="w-full"
disabled={loadingPlan !== null}
onClick={handleDowngrade}
>
{loadingPlan === 'FREE' ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Cancelling...
</span>
) : (
'Downgrade to Free'
)}
</Button>
) : plan.key === 'FREE' ? (
<Button variant="outline" className="w-full" disabled>
Included
@@ -206,6 +286,8 @@ export default function UpgradePage() {
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
</span>
) : hasPlanDifferentInterval ? (
`Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
) : (
`Upgrade to ${plan.name}`
)}

View File

@@ -0,0 +1,204 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
import { triggerLifecycleScoring } from '@/lib/revops-server';
import { qrStyleSchema } from '@/lib/validationSchemas';
import { buildVcard } from '@/lib/bulk-content';
import { z } from 'zod';
/**
* POST /api/qrs/bulk - create a whole upload in one request.
*
* The bulk page used to fire one POST per row against /api/qrs, which is rate
* limited to 20 creates a minute. A 100-row upload therefore lost 80 rows to
* 429s, and a 1000-row upload opened 1000 concurrent connections to do it.
* One request per batch also means the dynamic quota is checked once, against
* a live count, instead of racing itself row by row.
*/
export const maxDuration = 60;
const MAX_ITEMS = 1000;
const itemSchema = z.object({
title: z.string().min(1).max(100),
contentType: z.enum(['URL', 'VCARD', 'GEO', 'PHONE', 'TEXT']),
content: z.record(z.any()),
});
const bulkSchema = z.object({
items: z.array(itemSchema).min(1).max(MAX_ITEMS),
isStatic: z.boolean(),
style: qrStyleSchema.optional(),
});
/** Mirrors the switch in /api/qrs so a bulk code encodes what a single one does. */
function buildQrContent(contentType: string, content: any): string {
switch (contentType) {
case 'URL':
return content.url || 'https://example.com';
case 'PHONE':
return `tel:${content.phone}`;
case 'GEO': {
const lat = content.latitude ?? 0;
const lon = content.longitude ?? 0;
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
return `geo:${lat},${lon}${label}`;
}
case 'VCARD':
return buildVcard(content);
case 'TEXT':
return content.text || '';
default:
return content.url || '';
}
}
export async function POST(request: NextRequest) {
try {
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = getSessionUserId();
const clientId = userId || getClientIdentifier(request);
// One batch is one request, so the limit is on batches, not rows.
const rateLimitResult = rateLimit(clientId, RateLimits.QR_BULK_CREATE);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many bulk uploads. Please wait a moment and try again.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
},
{ status: 429 }
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: { plan: true },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const plan = user.plan || 'FREE';
// Bulk creation is a Business feature. The page hides itself for other
// plans, but the endpoint has to say so too - a hidden button is not a
// permission check.
if (plan !== 'BUSINESS' && (plan as string) !== 'ENTERPRISE') {
return NextResponse.json(
{
error: 'Upgrade required',
message: 'Bulk QR code creation is part of the Business plan.',
plan,
},
{ status: 403 }
);
}
let body;
try {
body = bulkSchema.parse(await request.json());
} catch (err) {
if (err instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: err.errors }, { status: 400 });
}
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
const { items, isStatic } = body;
const style = body.style ?? {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
};
// Work out how many rows the plan can actually take before writing
// anything, so the caller gets one honest answer instead of discovering
// the ceiling halfway through a print run.
let allowed = items.length;
if (!isStatic) {
const limit =
DYNAMIC_QR_LIMITS[plan as keyof typeof DYNAMIC_QR_LIMITS] ?? DYNAMIC_QR_LIMITS.FREE;
const used = await db.qRCode.count({
where: { userId, type: 'DYNAMIC', status: 'ACTIVE' },
});
allowed = Math.max(0, Math.min(items.length, limit - used));
}
// `row` is the 1-based index into the submitted items. The caller needs it
// to line results back up with its own rows once some of them have failed.
const created: { row: number; title: string; slug: string; id: string }[] = [];
const failed: { row: number; title: string; reason: string }[] = [];
for (let i = allowed; i < items.length; i++) {
failed.push({
row: i + 1,
title: items[i].title,
reason: 'No dynamic code slots left on your plan',
});
}
for (let i = 0; i < allowed; i++) {
const item = items[i];
const content = isStatic
? { ...item.content, qrContent: buildQrContent(item.contentType, item.content) }
: item.content;
// Slugs carry six random characters, so a clash across a large batch is
// unlikely but not impossible. Retrying beats failing the whole upload.
let saved = false;
for (let attempt = 0; attempt < 3 && !saved; attempt++) {
try {
const qr = await db.qRCode.create({
data: {
userId,
title: item.title,
type: isStatic ? 'STATIC' : 'DYNAMIC',
contentType: item.contentType,
content,
tags: [],
style,
slug: generateSlug(item.title),
status: 'ACTIVE',
},
select: { id: true, slug: true, title: true },
});
created.push({ ...qr, row: i + 1 });
saved = true;
} catch (err: any) {
if (err?.code === 'P2002' && attempt < 2) continue;
failed.push({
row: i + 1,
title: item.title,
reason: err?.code === 'P2002' ? 'Could not allocate a unique link' : 'Could not be saved',
});
saved = true;
}
}
}
if (created.length > 0) {
triggerLifecycleScoring(userId, 'qr_created');
}
return NextResponse.json({ created, failed });
} catch (error) {
console.error('Error creating QR codes in bulk:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -7,6 +7,7 @@ import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
import { triggerLifecycleScoring } from '@/lib/revops-server';
import { buildVcard } from '@/lib/bulk-content';
// GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) {
@@ -173,15 +174,10 @@ export async function POST(request: NextRequest) {
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
break;
case 'VCARD':
qrContent = `BEGIN:VCARD
VERSION:3.0
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
${body.content.organization ? `ORG:${body.content.organization}` : ''}
${body.content.title ? `TITLE:${body.content.title}` : ''}
${body.content.email ? `EMAIL:${body.content.email}` : ''}
${body.content.phone ? `TEL:${body.content.phone}` : ''}
END:VCARD`;
// Shared with the bulk endpoint. Two copies of this template drifting
// apart would mean the same contact encodes differently depending on
// which route created it.
qrContent = buildVcard(body.content);
break;
case 'GEO':
const lat = body.content.latitude || 0;