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 { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select'; import { Select } from '@/components/ui/Select';
import { QRCodeSVG } from 'qrcode.react';
import { renderStyledQRSvg } from '@/lib/render-qr-svg'; import { renderStyledQRSvg } from '@/lib/render-qr-svg';
import {
cellToString,
detectBulkContent,
presetStyleToQrStyle,
type DetectedContent,
} from '@/lib/bulk-content';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
@@ -28,6 +33,8 @@ interface GeneratedQR {
svg: string; svg: string;
slug?: string; slug?: string;
redirectUrl?: string; redirectUrl?: string;
/** Kept so the save step stores the same code that was previewed. */
detected?: DetectedContent;
} }
export default function BulkCreationPage() { export default function BulkCreationPage() {
@@ -39,16 +46,24 @@ export default function BulkCreationPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]); const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
const [userPlan, setUserPlan] = useState<string>('FREE'); 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 [isDynamic, setIsDynamic] = useState(false);
const [remainingDynamic, setRemainingDynamic] = useState(0); const [remainingDynamic, setRemainingDynamic] = useState(0);
// Rows the API refused. Previously these vanished silently and the success // Rows the API refused. Previously these vanished silently and the success
// toast reported a smaller number with no explanation - the worst kind of // toast reported a smaller number with no explanation - the worst kind of
// failure for someone who is about to send a batch to print. // failure for someone who is about to send a batch to print.
const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]); const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]);
// A saved design applied to the whole batch. This is the reason presets exist: // 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. // 500 codes that all look like the same client, from one upload.
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]); const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
const [presetId, setPresetId] = useState(''); 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 // 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 // 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'); const statsRes = await fetch('/api/user/stats');
if (statsRes.ok) { if (statsRes.ok) {
const stats = await statsRes.json(); const stats = await statsRes.json();
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)); setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
} }
} catch (error) { } catch (error) {
console.error('Error refreshing quota:', 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; 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 // Check user plan and dynamic quota on mount
React.useEffect(() => { React.useEffect(() => {
const checkPlan = async () => { const checkPlan = async () => {
@@ -88,10 +108,12 @@ export default function BulkCreationPage() {
} }
if (statsRes.ok) { if (statsRes.ok) {
const stats = await statsRes.json(); const stats = await statsRes.json();
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)); setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
} }
} catch (error) { } catch (error) {
console.error('Error checking plan:', error); console.error('Error checking plan:', error);
} finally {
setPlanLoaded(true);
} }
}; };
checkPlan(); checkPlan();
@@ -193,44 +215,33 @@ export default function BulkCreationPage() {
try { try {
const qrCodes: GeneratedQR[] = []; const qrCodes: GeneratedQR[] = [];
const style = activeStyle();
// Generate all QR codes client-side (Static QR Codes)
for (const row of data) { for (const row of data) {
const title = row[mapping.title as keyof typeof row] || 'Untitled'; const title = safeTitle(row[mapping.title as keyof typeof row]);
const content = row[mapping.content as keyof typeof row] || 'https://example.com'; const rawContent = row[mapping.content as keyof typeof row];
// Create a temporary div to render QR code // The cell decides the code type. Encoding a phone number as a URL is
const tempDiv = document.createElement('div'); // how a batch of "static QR codes" ended up scanning as broken links.
tempDiv.style.display = 'none'; const detected = detectBulkContent(rawContent);
document.body.appendChild(tempDiv);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); // One renderer for both plain and styled codes. The plain path used to
svg.setAttribute('width', '300'); // go through QRCode.toString at error correction M while the styled
svg.setAttribute('height', '300'); // path used H, so the same row produced two different codes depending
tempDiv.appendChild(svg); // on whether a preset was selected.
const qrSvg = renderStyledQRSvg(detected.qrValue, style, 300);
// 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' },
});
qrCodes.push({ qrCodes.push({
title: String(title), title,
content: String(content), // Store the original URL content: cellToString(rawContent),
svg: qrSvg, svg: qrSvg,
detected,
}); });
document.body.removeChild(tempDiv);
} }
setGeneratedQRs(qrCodes); setGeneratedQRs(qrCodes);
setFailedRows([]);
setSavedToDashboard(false);
setStep('complete'); setStep('complete');
showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success'); showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success');
} catch (error) { } catch (error) {
@@ -243,73 +254,92 @@ export default function BulkCreationPage() {
const generateDynamicQRCodes = async () => { const generateDynamicQRCodes = async () => {
setLoading(true); 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 { try {
const QRCode = require('qrcode');
const results: GeneratedQR[] = [];
const failures: { row: number; title: string; reason: string }[] = []; 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 data.forEach((row: any, i) => {
// reflects the whole upload and not just the slice we attempted. const title = safeTitle(row[mapping.title as keyof typeof row]);
data.slice(toProcess.length).forEach((row, i) => { const detected = detectBulkContent(row[mapping.content as keyof typeof row]);
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',
});
});
for (let i = 0; i < toProcess.length; i++) { // A dynamic code is a redirect, so anything that is not a link cannot
const row = toProcess[i]; // become one. Saying so up front beats storing a code that leads nowhere.
const title = String(row[mapping.title as keyof typeof row] || 'Untitled'); if (detected.contentType !== 'URL') {
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);
failures.push({ failures.push({
row: i + 1, row: i + 1,
title, title,
reason: err?.error === 'Limit reached' reason: 'Dynamic codes need a web address in the content column',
? 'No dynamic code slots left on your plan'
: err?.error || `Request failed (${res.status})`,
}); });
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); setGeneratedQRs(results);
setFailedRows(failures); setFailedRows(failures);
// Dynamic codes exist in the dashboard the moment they are generated.
setSavedToDashboard(true);
await refreshQuota(); await refreshQuota();
setStep('complete'); setStep('complete');
@@ -372,37 +402,63 @@ export default function BulkCreationPage() {
}; };
const saveQRCodesToDatabase = async () => { 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); setLoading(true);
try { try {
const qrCodesToSave = generatedQRs.map((qr) => ({ const items = generatedQRs.map((qr) => {
title: qr.title, const detected = qr.detected ?? detectBulkContent(qr.content);
isStatic: true, // This tells the API it's a static QR code return {
contentType: 'URL', title: qr.title,
content: { url: qr.content }, // Content needs to be an object with url property contentType: detected.contentType,
status: 'ACTIVE', content: detected.content,
})); };
});
// Save each QR code to the database // The design goes with them. Without this the batch was previewed in the
const savePromises = qrCodesToSave.map((qr) => // user's own branding and then stored as plain black and white - the code
fetchWithCsrf('/api/qrs', { // on screen and the code in the dashboard were not the same picture.
method: 'POST', const res = await fetchWithCsrf('/api/qrs/bulk', {
body: JSON.stringify(qr), method: 'POST',
}) body: JSON.stringify({
); items,
isStatic: true,
style: presetStyleToQrStyle(activeStyle()),
}),
});
const results = await Promise.all(savePromises); if (!res.ok) {
const failedCount = results.filter((r) => !r.ok).length; const err = await res.json().catch(() => null);
showToast(err?.message || err?.error || 'Failed to save QR codes', 'error');
return;
}
if (failedCount === 0) { const { created, failed } = (await res.json()) as {
showToast(`Successfully saved ${qrCodesToSave.length} QR codes!`, 'success'); created: unknown[];
// Redirect to dashboard after 1 second failed: { row: number; title: string; reason: string }[];
};
setSavedToDashboard(true);
if (failed.length === 0) {
showToast(`Successfully saved ${created.length} QR codes!`, 'success');
setTimeout(() => { setTimeout(() => {
window.location.href = '/dashboard'; window.location.href = '/dashboard';
}, 1000); }, 1000);
} else { } 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) { } catch (error) {
console.error('Error saving QR codes:', error); console.error('Error saving QR codes:', error);
@@ -437,6 +493,21 @@ export default function BulkCreationPage() {
URL.revokeObjectURL(url); 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 // Show upgrade prompt if not Business or Enterprise plan
if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') { if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') {
return ( return (
@@ -473,26 +544,26 @@ export default function BulkCreationPage() {
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1> <h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p> <p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
{/* Apply a saved design to the whole batch. */} {/* Apply a saved design to the whole batch. */}
{presets.length > 0 && ( {presets.length > 0 && (
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 p-4"> <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"> <label className="mb-2 block text-sm font-medium text-gray-700">
Design preset Design preset
</label> </label>
<Select <Select
value={presetId} value={presetId}
onChange={(e) => setPresetId(e.target.value)} onChange={(e) => setPresetId(e.target.value)}
options={[ options={[
{ value: '', label: 'Plain black and white' }, { value: '', label: 'Plain black and white' },
...presets.map((p) => ({ value: p.id, label: p.name })), ...presets.map((p) => ({ value: p.id, label: p.name })),
]} ]}
/> />
<p className="mt-2 text-xs text-gray-500"> <p className="mt-2 text-xs text-gray-500">
Applied to every code in this upload, so the whole batch matches. Applied to every code in this upload, so the whole batch matches.
</p> </p>
</div> </div>
)} )}
{/* Static / Dynamic Toggle */} {/* Static / Dynamic Toggle */}
<div className="mt-4 flex items-center gap-4 p-4 bg-gray-50 rounded-xl border border-gray-200"> <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> <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"> <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" /> <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> </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> <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"> <p className="text-sm text-blue-800">
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation. {isDynamic ? (
These QR codes do not include tracking or analytics. Perfect for print materials and offline use. <>
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> </p>
</div> </div>
</div> </div>
@@ -827,22 +912,37 @@ export default function BulkCreationPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.slice(0, 5).map((row: any, index) => ( {data.slice(0, 5).map((row: any, index) => {
<tr key={index} className="border-b"> // Same detection and same renderer as the real run, so this
<td className="py-3 px-4"> // table is a preview rather than a lookalike.
<QRCodeSVG const detected = detectBulkContent(row[mapping.content]);
value={row[mapping.content] || 'https://example.com'} const raw = cellToString(row[mapping.content]);
size={40} return (
/> <tr key={index} className="border-b">
</td> <td className="py-3 px-4">
<td className="py-3 px-4 text-sm text-gray-900"> <div
{row[mapping.title] || 'Untitled'} className="h-10 w-10"
</td> dangerouslySetInnerHTML={{
<td className="py-3 px-4 text-sm text-gray-900"> __html: renderStyledQRSvg(
{(row[mapping.content] || '').substring(0, 50)}... detected.qrValue || 'https://example.com',
</td> activeStyle(),
</tr> 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> </tbody>
</table> </table>
</div> </div>
@@ -862,7 +962,7 @@ export default function BulkCreationPage() {
loading={loading} loading={loading}
> >
{isDynamic {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`} : `Generate ${data.length} Static QR Codes`}
</Button> </Button>
</div> </div>
@@ -887,7 +987,9 @@ export default function BulkCreationPage() {
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">
{failedRows.length > 0 {failedRows.length > 0
? 'The rows below could not be added. Nothing was silently dropped - here is exactly what is missing.' ? '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> </p>
{failedRows.length > 0 && ( {failedRows.length > 0 && (
@@ -959,6 +1061,10 @@ export default function BulkCreationPage() {
setData([]); setData([]);
setMapping({}); setMapping({});
setGeneratedQRs([]); 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 Create More
</Button> </Button>
@@ -968,8 +1074,21 @@ export default function BulkCreationPage() {
</svg> </svg>
Download All as ZIP Download All as ZIP
</Button> </Button>
{!isDynamic && ( {/* There is always an action here. The Save button used to be
<Button onClick={saveQRCodesToDatabase} loading={loading}> 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"> <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" /> <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> </svg>

View File

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

View File

@@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { BillingToggle } from '@/components/ui/BillingToggle';
import { trackEvent } from '@/components/PostHogProvider'; import { trackEvent } from '@/components/PostHogProvider';
import { Check, Loader2 } from 'lucide-react'; import { Check, Loader2 } from 'lucide-react';
import { import {
@@ -15,6 +16,7 @@ import {
} from '@/lib/plans'; } from '@/lib/plans';
type PlanKey = 'FREE' | 'PRO' | 'BUSINESS'; type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
type BillingInterval = 'month' | 'year';
/** /**
* In-app upgrade page. * In-app upgrade page.
@@ -31,7 +33,8 @@ type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
const PLANS: { const PLANS: {
key: PlanKey; key: PlanKey;
name: string; name: string;
price: string; priceMonth: string;
priceYear: string;
period: string; period: string;
caption: string; caption: string;
features: string[]; features: string[];
@@ -40,7 +43,8 @@ const PLANS: {
{ {
key: 'FREE', key: 'FREE',
name: 'Free', name: 'Free',
price: '€0', priceMonth: '€0',
priceYear: '€0',
period: 'forever', period: 'forever',
caption: 'Enough to prove the idea on one or two placements.', caption: 'Enough to prove the idea on one or two placements.',
features: [ features: [
@@ -54,7 +58,8 @@ const PLANS: {
{ {
key: 'PRO', key: 'PRO',
name: 'Pro', name: 'Pro',
price: '€9', priceMonth: '€9',
priceYear: '€90',
period: 'per month', period: 'per month',
popular: true, popular: true,
caption: 'When one campaign is no longer the only campaign.', caption: 'When one campaign is no longer the only campaign.',
@@ -69,13 +74,14 @@ const PLANS: {
{ {
key: 'BUSINESS', key: 'BUSINESS',
name: 'Business', name: 'Business',
price: '€29', priceMonth: '€29',
priceYear: '€290',
period: 'per month', period: 'per month',
caption: 'When codes are produced in batches, not one at a time.', caption: 'When codes are produced in batches, not one at a time.',
features: [ features: [
`${BUSINESS_DYNAMIC_QR_LIMIT} dynamic QR codes`, `${BUSINESS_DYNAMIC_QR_LIMIT} dynamic QR codes`,
'Bulk creation: 1,000 static or 500 dynamic per upload', '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', 'Saved design presets, applied to a whole bulk upload',
'Priority email support', 'Priority email support',
'Everything in Pro', 'Everything in Pro',
@@ -94,6 +100,8 @@ const REASON_HEADLINES: Record<string, string> = {
export default function UpgradePage() { export default function UpgradePage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [currentPlan, setCurrentPlan] = useState<PlanKey>('FREE'); 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 [loadingPlan, setLoadingPlan] = useState<PlanKey | null>(null);
const reason = searchParams.get('reason'); const reason = searchParams.get('reason');
@@ -102,7 +110,10 @@ export default function UpgradePage() {
useEffect(() => { useEffect(() => {
fetch('/api/user/plan') fetch('/api/user/plan')
.then((r) => (r.ok ? r.json() : null)) .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(() => {}); .catch(() => {});
}, []); }, []);
@@ -115,7 +126,12 @@ export default function UpgradePage() {
const handleUpgrade = async (plan: PlanKey) => { const handleUpgrade = async (plan: PlanKey) => {
if (plan === 'FREE') return; if (plan === 'FREE') return;
setLoadingPlan(plan); setLoadingPlan(plan);
trackEvent('upgrade_clicked', { plan, source: 'in_app_upgrade', reason }); trackEvent('upgrade_clicked', {
plan,
billing_interval: billingPeriod,
source: 'in_app_upgrade',
reason,
});
try { try {
const res = await fetch('/api/stripe/create-checkout-session', { const res = await fetch('/api/stripe/create-checkout-session', {
@@ -123,7 +139,7 @@ export default function UpgradePage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
plan, plan,
billingInterval: 'month', billingInterval: billingPeriod,
returnPath: returnTo && returnTo.startsWith('/') ? returnTo : '/dashboard', 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 ( return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8"> <div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
<div className="mb-10 max-w-2xl"> <div className="mb-10 max-w-2xl">
@@ -156,9 +200,25 @@ export default function UpgradePage() {
</p> </p>
</div> </div>
<div className="mb-8 flex justify-center">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div>
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{PLANS.map((plan) => { {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 ( return (
<Card <Card
key={plan.key} key={plan.key}
@@ -172,9 +232,14 @@ export default function UpgradePage() {
</div> </div>
<div className="mb-1 flex items-baseline gap-2"> <div className="mb-1 flex items-baseline gap-2">
<span className="text-4xl font-bold text-slate-900">{plan.price}</span> <span className="text-4xl font-bold text-slate-900">{price}</span>
<span className="text-sm text-slate-500">{plan.period}</span> <span className="text-sm text-slate-500">{period}</span>
</div> </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> <p className="mb-6 text-sm text-slate-600">{plan.caption}</p>
<ul className="mb-8 space-y-3"> <ul className="mb-8 space-y-3">
@@ -191,6 +256,21 @@ export default function UpgradePage() {
<Button variant="outline" className="w-full" disabled> <Button variant="outline" className="w-full" disabled>
Current plan Current plan
</Button> </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' ? ( ) : plan.key === 'FREE' ? (
<Button variant="outline" className="w-full" disabled> <Button variant="outline" className="w-full" disabled>
Included Included
@@ -206,6 +286,8 @@ export default function UpgradePage() {
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout... <Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
</span> </span>
) : hasPlanDifferentInterval ? (
`Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
) : ( ) : (
`Upgrade to ${plan.name}` `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 { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans'; import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
import { triggerLifecycleScoring } from '@/lib/revops-server'; import { triggerLifecycleScoring } from '@/lib/revops-server';
import { buildVcard } from '@/lib/bulk-content';
// GET /api/qrs - List user's QR codes // GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) { 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)}` : ''}`; qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
break; break;
case 'VCARD': case 'VCARD':
qrContent = `BEGIN:VCARD // Shared with the bulk endpoint. Two copies of this template drifting
VERSION:3.0 // apart would mean the same contact encodes differently depending on
FN:${body.content.firstName || ''} ${body.content.lastName || ''} // which route created it.
N:${body.content.lastName || ''};${body.content.firstName || ''};;; qrContent = buildVcard(body.content);
${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`;
break; break;
case 'GEO': case 'GEO':
const lat = body.content.latitude || 0; 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 { X, Loader2, Pause, Download, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
export type UpgradeReason = 'limit' | 'logo' | 'shapes'; export type UpgradeReason = 'limit' | 'logo' | 'shapes' | 'business-shapes';
export interface ActiveCodeSummary { export interface ActiveCodeSummary {
id: string; id: string;
@@ -15,6 +15,11 @@ export interface ActiveCodeSummary {
interface UpgradeModalProps { interface UpgradeModalProps {
open: boolean; open: boolean;
reason: UpgradeReason; 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". */ /** How many dynamic codes the user already has. Only used for reason="limit". */
currentCount?: number; currentCount?: number;
/** The plan limit that was hit. Only used for reason="limit". */ /** 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 * hostage, and this is the one screen where that has to be demonstrated
* rather than claimed. * rather than claimed.
*/ */
function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) { function getCopy(reason: UpgradeReason, plan: string, currentCount?: number, limit?: number) {
if (reason === 'logo') { if (reason === 'logo') {
return { return {
headline: 'Your logo belongs inside this code.', 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.', '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', cta: 'Add my logo - €9 / month',
reassurance: 'Your current codes keep working exactly as they are.', 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.', 'Pro unlocks four module shapes and your own eye styles. Colors stay free on every plan.',
cta: 'Unlock shapes - €9 / month', cta: 'Unlock shapes - €9 / month',
reassurance: 'Your current codes keep working exactly as they are.', 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 next = (currentCount ?? 3) + 1;
const cap = limit ?? 3; 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 { return {
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`, 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.`, 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.', 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', cta: 'Save this code with Pro - €9 / month',
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`, reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
ctaType: 'checkout' as const,
}; };
} }
export default function UpgradeModal({ export default function UpgradeModal({
open, open,
reason, reason,
plan = 'FREE',
currentCount, currentCount,
limit, limit,
activeCodes = [], activeCodes = [],
@@ -119,7 +165,15 @@ export default function UpgradeModal({
if (!open) return null; 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 () => { const handleCheckout = async () => {
setCheckoutLoading(true); setCheckoutLoading(true);
@@ -135,7 +189,7 @@ export default function UpgradeModal({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
plan: 'PRO', plan: targetPlan,
billingInterval: 'month', billingInterval: 'month',
returnPath: path, returnPath: path,
}), }),
@@ -205,21 +259,30 @@ export default function UpgradeModal({
</div> </div>
)} )}
<Button {copy.ctaType === 'contact' ? (
onClick={handleCheckout} <a
disabled={checkoutLoading} href="mailto:support@qrmaster.net?subject=Higher%20dynamic%20QR%20limit"
className="h-12 w-full bg-primary-600 text-base font-semibold text-white hover:bg-primary-700" 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"
> >
{checkoutLoading ? ( {copy.cta} <ArrowRight className="h-4 w-4" />
<span className="flex items-center justify-center gap-2"> </a>
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout... ) : (
</span> <Button
) : ( onClick={handleCheckout}
<span className="flex items-center justify-center gap-2"> disabled={checkoutLoading}
{copy.cta} <ArrowRight className="h-4 w-4" /> className="h-12 w-full bg-primary-600 text-base font-semibold text-white hover:bg-primary-700"
</span> >
)} {checkoutLoading ? (
</Button> <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 && ( {reason === 'limit' && onPauseCode && activeCodes.length > 0 && !showCodeList && (
<button <button

View File

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