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;

View File

@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { X, Loader2, Pause, Download, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/Button';
export type UpgradeReason = 'limit' | 'logo' | 'shapes';
export type UpgradeReason = 'limit' | 'logo' | 'shapes' | 'business-shapes';
export interface ActiveCodeSummary {
id: string;
@@ -15,6 +15,11 @@ export interface ActiveCodeSummary {
interface UpgradeModalProps {
open: boolean;
reason: UpgradeReason;
/** The user's current plan. Determines which plan is offered next
* (Free -> Pro, Pro -> Business) and whether a paid CTA makes sense
* at all (Business/Enterprise are already at or above the ceiling
* a plain checkout button can sell). Defaults to 'FREE'. */
plan?: string;
/** How many dynamic codes the user already has. Only used for reason="limit". */
currentCount?: number;
/** The plan limit that was hit. Only used for reason="limit". */
@@ -49,7 +54,7 @@ function ordinal(n: number): string {
* hostage, and this is the one screen where that has to be demonstrated
* rather than claimed.
*/
function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
function getCopy(reason: UpgradeReason, plan: string, currentCount?: number, limit?: number) {
if (reason === 'logo') {
return {
headline: 'Your logo belongs inside this code.',
@@ -58,6 +63,7 @@ function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
'Pro puts your logo in the center of every code you make, at the error-correction level that keeps it scannable.',
cta: 'Add my logo - €9 / month',
reassurance: 'Your current codes keep working exactly as they are.',
ctaType: 'checkout' as const,
};
}
@@ -69,24 +75,64 @@ function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
'Pro unlocks four module shapes and your own eye styles. Colors stay free on every plan.',
cta: 'Unlock shapes - €9 / month',
reassurance: 'Your current codes keep working exactly as they are.',
ctaType: 'checkout' as const,
};
}
if (reason === 'business-shapes') {
return {
headline: 'This shape needs Business.',
body: 'Classy, diamond, hexagon, plus, mosaic, liquid and star modules are the shapes agencies and larger teams use to make a code feel fully custom, not just styled.',
mechanism:
'Business unlocks every module shape, plus white-label branding and priority support. Colors stay free on every plan.',
cta: 'Unlock all shapes - €29 / month',
reassurance: 'Your current codes keep working exactly as they are.',
ctaType: 'checkout' as const,
};
}
const next = (currentCount ?? 3) + 1;
const cap = limit ?? 3;
// Free -> Pro is the default upsell. Pro -> Business and Business/Enterprise
// "already at the top" need their own copy - otherwise a Pro user who fills
// their 50 slots gets told to buy the Pro plan they already have.
if (plan === 'BUSINESS' || plan === 'ENTERPRISE') {
return {
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
body: `You are using all ${cap} dynamic codes on your Business plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
mechanism: 'You are already on our highest self-serve plan. For a higher ceiling than 500 dynamic codes, contact us directly.',
cta: 'Contact us about a higher limit',
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
ctaType: 'contact' as const,
};
}
if (plan === 'PRO') {
return {
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
body: `You are using all ${cap} dynamic codes on your Pro plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
mechanism: 'Business raises the ceiling to 500 dynamic codes and adds bulk creation and priority support.',
cta: 'Save this code with Business - €29 / month',
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
ctaType: 'checkout' as const,
};
}
return {
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
body: `You are using all ${cap} dynamic codes on your plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
mechanism: 'Pro raises the ceiling to 50 dynamic codes and adds device and location data for every scan.',
cta: 'Save this code with Pro - €9 / month',
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
ctaType: 'checkout' as const,
};
}
export default function UpgradeModal({
open,
reason,
plan = 'FREE',
currentCount,
limit,
activeCodes = [],
@@ -119,7 +165,15 @@ export default function UpgradeModal({
if (!open) return null;
const copy = getCopy(reason, currentCount, limit);
const copy = getCopy(reason, plan, currentCount, limit);
// Which plan a checkout should sell depends on where the user already is,
// not just why the modal opened. A Pro user hitting their code limit needs
// Business, not another Pro subscription.
const targetPlan =
reason === 'business-shapes' || (reason === 'limit' && plan === 'PRO')
? 'BUSINESS'
: 'PRO';
const handleCheckout = async () => {
setCheckoutLoading(true);
@@ -135,7 +189,7 @@ export default function UpgradeModal({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plan: 'PRO',
plan: targetPlan,
billingInterval: 'month',
returnPath: path,
}),
@@ -205,21 +259,30 @@ export default function UpgradeModal({
</div>
)}
<Button
onClick={handleCheckout}
disabled={checkoutLoading}
className="h-12 w-full bg-primary-600 text-base font-semibold text-white hover:bg-primary-700"
>
{checkoutLoading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
</span>
) : (
<span className="flex items-center justify-center gap-2">
{copy.cta} <ArrowRight className="h-4 w-4" />
</span>
)}
</Button>
{copy.ctaType === 'contact' ? (
<a
href="mailto:support@qrmaster.net?subject=Higher%20dynamic%20QR%20limit"
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary-600 text-base font-semibold text-white transition-colors hover:bg-primary-700"
>
{copy.cta} <ArrowRight className="h-4 w-4" />
</a>
) : (
<Button
onClick={handleCheckout}
disabled={checkoutLoading}
className="h-12 w-full bg-primary-600 text-base font-semibold text-white hover:bg-primary-700"
>
{checkoutLoading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
</span>
) : (
<span className="flex items-center justify-center gap-2">
{copy.cta} <ArrowRight className="h-4 w-4" />
</span>
)}
</Button>
)}
{reason === 'limit' && onPauseCode && activeCodes.length > 0 && !showCodeList && (
<button

View File

@@ -1,16 +1,16 @@
'use client';
import React from 'react';
import { QRCodeSVG } from 'qrcode.react';
import Barcode from 'react-barcode';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils';
import {
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
} from '@/lib/revops';
import StyledQRCode from '@/components/generator/StyledQRCode';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils';
import {
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
} from '@/lib/revops';
function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string {
const cloned = svgElement.cloneNode(true) as SVGElement;
@@ -63,21 +63,21 @@ interface QRCodeCardProps {
onDelete: (id: string) => void;
}
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDelete,
}) => {
const markDownloadComplete = () => {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1');
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
};
// For dynamic QR codes, use the redirect URL for tracking
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDelete,
}) => {
const markDownloadComplete = () => {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1');
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
};
// For dynamic QR codes, use the redirect URL for tracking
// For static QR codes, use the direct URL from content
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
@@ -138,11 +138,11 @@ END:VCARD`;
pixelRatio: 3,
backgroundColor: '#ffffff' // White background for clean export
});
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
} else {
// For SVG, if no frame, export just the QR code SVG for vector quality
// If frame exists, use toPng as fallback since HTML-to-SVG is complex
@@ -153,11 +153,11 @@ END:VCARD`;
pixelRatio: 3,
backgroundColor: '#ffffff'
});
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
const link = document.createElement('a');
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
link.href = dataUrl;
link.click();
markDownloadComplete();
} else {
// No frame - export clean SVG from the svg wrapper
const svgContainer = document.querySelector(`#qr-svg-${qr.id}`);
@@ -185,12 +185,12 @@ END:VCARD`;
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
a.click();
URL.revokeObjectURL(url);
markDownloadComplete();
const a = document.createElement('a');
a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
a.click();
URL.revokeObjectURL(url);
markDownloadComplete();
}
}
}
@@ -200,16 +200,16 @@ END:VCARD`;
};
return (
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
<CardContent className="p-4">
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
<div className="flex items-center space-x-2">
<Badge className={qr.type === 'DYNAMIC' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700'}>
{qr.type}
</Badge>
</div>
<Badge className={qr.type === 'DYNAMIC' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700'}>
{qr.type}
</Badge>
</div>
</div>
<Dropdown
@@ -234,12 +234,12 @@ END:VCARD`;
</Dropdown>
</div>
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
{/* Download wrapper - tightly wraps content */}
<div
id={`qr-download-${qr.id}`}
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
>
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
{/* Download wrapper - tightly wraps content */}
<div
id={`qr-download-${qr.id}`}
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
>
{/* Frame Label */}
{qr.style?.frameType && qr.style.frameType !== 'none' && (
<div
@@ -284,18 +284,31 @@ END:VCARD`;
</p>
</div>
) : (
<QRCodeSVG
/* Same renderer as the detail page and the bulk export.
qrcode.react only knows two colours, so a code saved with a
module shape, gradient or logo showed up here as a plain
black grid - the design looked lost even when it was stored. */
<StyledQRCode
value={qrUrl}
size={96}
fgColor={qr.style?.foregroundColor || '#000000'}
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
level="H"
imageSettings={qr.style?.imageSettings ? {
src: qr.style.imageSettings.src,
height: qr.style.imageSettings.height * (96 / 200),
width: qr.style.imageSettings.width * (96 / 200),
excavate: qr.style.imageSettings.excavate,
} : undefined}
moduleShape={qr.style?.moduleShape || 'square'}
eyeFrameShape={qr.style?.eyeFrameShape || 'square'}
eyeBallShape={qr.style?.eyeBallShape || 'square'}
gradient={
qr.style?.gradientMode && qr.style.gradientMode !== 'none'
? {
type: qr.style.gradientMode,
from: qr.style.foregroundColor || '#000000',
to: qr.style.gradientTo || '#000000',
}
: null
}
errorCorrection="H"
logoUrl={qr.style?.imageSettings?.src}
logoScale={(qr.style?.imageSettings?.width ?? 24) / 200}
margin={0}
/>
)}
</div>
@@ -318,7 +331,7 @@ END:VCARD`;
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
</div>
{qr.type === 'DYNAMIC' && (
<div className="border-t border-slate-100 pt-2">
<div className="border-t border-slate-100 pt-2">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
</p>
@@ -337,4 +350,4 @@ END:VCARD`;
</CardContent>
</Card>
);
};
};

165
src/lib/bulk-content.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Shared interpretation of a single spreadsheet cell for the bulk generator.
*
* The bulk page has always advertised URL, vCard, geo, phone and text support,
* but the save path hardcoded contentType 'URL'. The result was a code that
* scanned as a phone number in the downloaded SVG and as a broken link in the
* dashboard - the same row, two different codes.
*
* Both the client (which renders the SVG) and the API payload now go through
* this one function, so what is previewed, downloaded and stored cannot drift.
*/
export type BulkContentType = 'URL' | 'PHONE' | 'GEO' | 'VCARD' | 'TEXT';
export interface DetectedContent {
contentType: BulkContentType;
/** Shape expected by /api/qrs for this contentType. */
content: Record<string, any>;
/** Exactly what the QR modules should encode. */
qrValue: string;
}
const URL_RE = /^https?:\/\/\S+$/i;
const PHONE_RE = /^\+?[\d][\d\s()/.-]{5,}$/;
const GEO_RE = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*(?:,\s*(.+))?$/;
/**
* Cells do not arrive as strings. ExcelJS hands back numbers, Dates and rich
* text objects, and calling .substring on those is how the preview table used
* to throw on a perfectly valid file.
*/
export function cellToString(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (value instanceof Date) return value.toISOString();
if (typeof value === 'object') {
const v = value as any;
// ExcelJS hyperlink / formula / rich-text cell values.
if (typeof v.text === 'string') return v.text.trim();
if (typeof v.hyperlink === 'string') return v.hyperlink.trim();
if (typeof v.result === 'string' || typeof v.result === 'number') return String(v.result).trim();
if (Array.isArray(v.richText)) return v.richText.map((r: any) => r?.text ?? '').join('').trim();
}
return String(value).trim();
}
export function detectBulkContent(raw: unknown): DetectedContent {
const value = cellToString(raw);
if (!value) {
return { contentType: 'TEXT', content: { text: '' }, qrValue: '' };
}
if (URL_RE.test(value)) {
return { contentType: 'URL', content: { url: value }, qrValue: value };
}
// A bare domain is what people actually put in spreadsheets. Treating it as
// plain text produces a code that does nothing when scanned.
if (/^(www\.|[a-z0-9-]+\.[a-z]{2,}(\/|$))/i.test(value) && !value.includes(' ')) {
const url = `https://${value}`;
return { contentType: 'URL', content: { url }, qrValue: url };
}
const geo = GEO_RE.exec(value);
if (geo) {
const latitude = parseFloat(geo[1]);
const longitude = parseFloat(geo[2]);
if (Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180) {
const label = geo[3]?.trim() || '';
return {
contentType: 'GEO',
content: { latitude, longitude, label },
qrValue: `geo:${latitude},${longitude}${label ? `?q=${encodeURIComponent(label)}` : ''}`,
};
}
}
// firstName,lastName,email,phone,organization,title
if (value.includes('@') && value.split(',').length >= 3) {
const [firstName = '', lastName = '', email = '', phone = '', organization = '', title = ''] =
value.split(',').map((p) => p.trim());
return {
contentType: 'VCARD',
content: { firstName, lastName, email, phone, organization, title },
qrValue: buildVcard({ firstName, lastName, email, phone, organization, title }),
};
}
if (PHONE_RE.test(value)) {
const phone = value.replace(/[\s()/.-]/g, '');
return { contentType: 'PHONE', content: { phone }, qrValue: `tel:${phone}` };
}
return { contentType: 'TEXT', content: { text: value }, qrValue: value };
}
/**
* Kept byte-identical to the vCard the API builds for a static code, so the
* downloaded SVG and the stored code encode the same thing.
*/
export function buildVcard(c: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
organization?: string;
title?: string;
}): string {
return `BEGIN:VCARD
VERSION:3.0
FN:${c.firstName || ''} ${c.lastName || ''}
N:${c.lastName || ''};${c.firstName || ''};;;
${c.organization ? `ORG:${c.organization}` : ''}
${c.title ? `TITLE:${c.title}` : ''}
${c.email ? `EMAIL:${c.email}` : ''}
${c.phone ? `TEL:${c.phone}` : ''}
END:VCARD`;
}
/**
* A saved design preset and a stored QR style describe the same design with
* different field names for the logo. Converting once, here, is why a preset
* applied to a batch still shows up on the dashboard afterwards.
*/
export function presetStyleToQrStyle(style: any | null | undefined): Record<string, any> | undefined {
if (!style) return undefined;
const {
logoUrl,
logoSize,
foregroundColor,
backgroundColor,
moduleShape,
eyeFrameShape,
eyeBallShape,
gradientMode,
gradientTo,
frameType,
} = style;
return {
foregroundColor: foregroundColor || '#000000',
backgroundColor: backgroundColor || '#FFFFFF',
cornerStyle: 'square' as const,
size: 200,
...(moduleShape ? { moduleShape } : {}),
...(eyeFrameShape ? { eyeFrameShape } : {}),
...(eyeBallShape ? { eyeBallShape } : {}),
...(gradientMode ? { gradientMode } : {}),
...(gradientTo ? { gradientTo } : {}),
...(frameType ? { frameType } : {}),
...(logoUrl
? {
imageSettings: {
src: logoUrl,
height: logoSize ?? 24,
width: logoSize ?? 24,
excavate: true,
},
}
: {}),
};
}

View File

@@ -139,6 +139,15 @@ export const RateLimits = {
windowSeconds: 60,
},
// Bulk create: 10 batches per minute.
// One upload is one request here, so this limits uploads rather than rows.
// Reusing QR_CREATE would have capped a batch at 20 codes.
QR_BULK_CREATE: {
name: 'qr-bulk-create',
maxRequests: 10,
windowSeconds: 60,
},
// Modify QR: 30 per minute
QR_MODIFY: {
name: 'qr-modify',