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,6 +46,10 @@ 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
@@ -49,6 +60,10 @@ export default function BulkCreationPage() {
// 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 (
@@ -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

@@ -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>
@@ -1173,7 +1173,7 @@ export default function CreatePage() {
<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'
@@ -1190,12 +1190,12 @@ export default function CreatePage() {
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',
@@ -1265,8 +1265,8 @@ export default function CreatePage() {
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;
} }
@@ -1371,14 +1371,14 @@ export default function CreatePage() {
{/* 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,7 +74,8 @@ 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: [
@@ -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,8 +1,8 @@
'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 StyledQRCode from '@/components/generator/StyledQRCode';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
@@ -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>

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',