fix V2
This commit is contained in:
@@ -9,8 +9,13 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
|
||||
import {
|
||||
cellToString,
|
||||
detectBulkContent,
|
||||
presetStyleToQrStyle,
|
||||
type DetectedContent,
|
||||
} from '@/lib/bulk-content';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
@@ -28,6 +33,8 @@ interface GeneratedQR {
|
||||
svg: string;
|
||||
slug?: string;
|
||||
redirectUrl?: string;
|
||||
/** Kept so the save step stores the same code that was previewed. */
|
||||
detected?: DetectedContent;
|
||||
}
|
||||
|
||||
export default function BulkCreationPage() {
|
||||
@@ -39,16 +46,24 @@ export default function BulkCreationPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
|
||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||
// Until the plan has actually come back from the server we know nothing.
|
||||
// Defaulting to FREE and rendering the paywall meant every Business user saw
|
||||
// "upgrade to Business" flash before their own page appeared.
|
||||
const [planLoaded, setPlanLoaded] = useState(false);
|
||||
const [isDynamic, setIsDynamic] = useState(false);
|
||||
const [remainingDynamic, setRemainingDynamic] = useState(0);
|
||||
// Rows the API refused. Previously these vanished silently and the success
|
||||
// toast reported a smaller number with no explanation - the worst kind of
|
||||
// failure for someone who is about to send a batch to print.
|
||||
const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]);
|
||||
// A saved design applied to the whole batch. This is the reason presets exist:
|
||||
// 500 codes that all look like the same client, from one upload.
|
||||
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
|
||||
const [failedRows, setFailedRows] = useState<{ row: number; title: string; reason: string }[]>([]);
|
||||
// A saved design applied to the whole batch. This is the reason presets exist:
|
||||
// 500 codes that all look like the same client, from one upload.
|
||||
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
|
||||
const [presetId, setPresetId] = useState('');
|
||||
// Whether this batch is already in the dashboard. Dynamic codes are written
|
||||
// during generation; static ones only when the button is pressed. Without
|
||||
// tracking it, pressing Save twice created the whole batch twice.
|
||||
const [savedToDashboard, setSavedToDashboard] = useState(false);
|
||||
|
||||
// Reload the remaining dynamic quota from the server. Counting down locally
|
||||
// drifts as soon as anything is created in another tab, which is how rows
|
||||
@@ -58,7 +73,7 @@ export default function BulkCreationPage() {
|
||||
const statsRes = await fetch('/api/user/stats');
|
||||
if (statsRes.ok) {
|
||||
const stats = await statsRes.json();
|
||||
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0));
|
||||
setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing quota:', error);
|
||||
@@ -74,6 +89,11 @@ export default function BulkCreationPage() {
|
||||
|
||||
const activeStyle = () => presets.find((p) => p.id === presetId)?.style ?? null;
|
||||
|
||||
// Titles are capped at 100 characters server-side. A single long cell used to
|
||||
// reject the whole upload with a validation error that named no row.
|
||||
const safeTitle = (value: unknown) =>
|
||||
(cellToString(value) || 'Untitled').slice(0, 100);
|
||||
|
||||
// Check user plan and dynamic quota on mount
|
||||
React.useEffect(() => {
|
||||
const checkPlan = async () => {
|
||||
@@ -88,10 +108,12 @@ export default function BulkCreationPage() {
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
const stats = await statsRes.json();
|
||||
setRemainingDynamic((stats.dynamicLimit || 0) - (stats.dynamicUsed || 0));
|
||||
setRemainingDynamic(Math.max(0, (stats.dynamicLimit || 0) - (stats.dynamicUsed || 0)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking plan:', error);
|
||||
} finally {
|
||||
setPlanLoaded(true);
|
||||
}
|
||||
};
|
||||
checkPlan();
|
||||
@@ -193,44 +215,33 @@ export default function BulkCreationPage() {
|
||||
|
||||
try {
|
||||
const qrCodes: GeneratedQR[] = [];
|
||||
const style = activeStyle();
|
||||
|
||||
// Generate all QR codes client-side (Static QR Codes)
|
||||
for (const row of data) {
|
||||
const title = row[mapping.title as keyof typeof row] || 'Untitled';
|
||||
const content = row[mapping.content as keyof typeof row] || 'https://example.com';
|
||||
const title = safeTitle(row[mapping.title as keyof typeof row]);
|
||||
const rawContent = row[mapping.content as keyof typeof row];
|
||||
|
||||
// Create a temporary div to render QR code
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
// The cell decides the code type. Encoding a phone number as a URL is
|
||||
// how a batch of "static QR codes" ended up scanning as broken links.
|
||||
const detected = detectBulkContent(rawContent);
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '300');
|
||||
svg.setAttribute('height', '300');
|
||||
tempDiv.appendChild(svg);
|
||||
|
||||
// Use qrcode library to generate SVG
|
||||
const QRCode = require('qrcode');
|
||||
const style = activeStyle();
|
||||
const qrSvg = style
|
||||
? renderStyledQRSvg(String(content), style, 300)
|
||||
: await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
});
|
||||
// One renderer for both plain and styled codes. The plain path used to
|
||||
// go through QRCode.toString at error correction M while the styled
|
||||
// path used H, so the same row produced two different codes depending
|
||||
// on whether a preset was selected.
|
||||
const qrSvg = renderStyledQRSvg(detected.qrValue, style, 300);
|
||||
|
||||
qrCodes.push({
|
||||
title: String(title),
|
||||
content: String(content), // Store the original URL
|
||||
title,
|
||||
content: cellToString(rawContent),
|
||||
svg: qrSvg,
|
||||
detected,
|
||||
});
|
||||
|
||||
document.body.removeChild(tempDiv);
|
||||
}
|
||||
|
||||
setGeneratedQRs(qrCodes);
|
||||
setFailedRows([]);
|
||||
setSavedToDashboard(false);
|
||||
setStep('complete');
|
||||
showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success');
|
||||
} catch (error) {
|
||||
@@ -243,73 +254,92 @@ export default function BulkCreationPage() {
|
||||
|
||||
const generateDynamicQRCodes = async () => {
|
||||
setLoading(true);
|
||||
const toProcess = remainingDynamic > 0 ? data.slice(0, remainingDynamic) : [];
|
||||
|
||||
if (toProcess.length === 0) {
|
||||
showToast('No dynamic QR codes left on your plan. Free a slot or upgrade to continue.', 'error');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length > remainingDynamic) {
|
||||
showToast(
|
||||
`Only ${remainingDynamic} dynamic codes left. The first ${remainingDynamic} rows will be processed - the rest are listed after the run.`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const QRCode = require('qrcode');
|
||||
const results: GeneratedQR[] = [];
|
||||
const failures: { row: number; title: string; reason: string }[] = [];
|
||||
const items: { title: string; contentType: 'URL'; content: { url: string } }[] = [];
|
||||
// Which upload row each accepted item came from, so the failure list the
|
||||
// server sends back can be mapped to the row the user actually sees.
|
||||
const rowOfItem: number[] = [];
|
||||
|
||||
// Rows the plan could not cover are counted from the start, so the summary
|
||||
// reflects the whole upload and not just the slice we attempted.
|
||||
data.slice(toProcess.length).forEach((row, i) => {
|
||||
failures.push({
|
||||
row: toProcess.length + i + 1,
|
||||
title: String(row[mapping.title as keyof typeof row] || 'Untitled'),
|
||||
reason: 'No dynamic code slots left on your plan',
|
||||
});
|
||||
});
|
||||
data.forEach((row: any, i) => {
|
||||
const title = safeTitle(row[mapping.title as keyof typeof row]);
|
||||
const detected = detectBulkContent(row[mapping.content as keyof typeof row]);
|
||||
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const row = toProcess[i];
|
||||
const title = String(row[mapping.title as keyof typeof row] || 'Untitled');
|
||||
const url = String(row[mapping.content as keyof typeof row] || 'https://example.com');
|
||||
|
||||
const res = await fetchWithCsrf('/api/qrs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
contentType: 'URL',
|
||||
content: { url },
|
||||
isStatic: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const qr = await res.json();
|
||||
const redirectUrl = `${window.location.origin}/r/${qr.slug}`;
|
||||
const style = activeStyle();
|
||||
const svg = style
|
||||
? renderStyledQRSvg(redirectUrl, style, 300)
|
||||
: await QRCode.toString(redirectUrl, { type: 'svg', width: 300, margin: 2 });
|
||||
results.push({ title, content: url, svg, slug: qr.slug, redirectUrl });
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
// A dynamic code is a redirect, so anything that is not a link cannot
|
||||
// become one. Saying so up front beats storing a code that leads nowhere.
|
||||
if (detected.contentType !== 'URL') {
|
||||
failures.push({
|
||||
row: i + 1,
|
||||
title,
|
||||
reason: err?.error === 'Limit reached'
|
||||
? 'No dynamic code slots left on your plan'
|
||||
: err?.error || `Request failed (${res.status})`,
|
||||
reason: 'Dynamic codes need a web address in the content column',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
items.push({ title, contentType: 'URL', content: { url: detected.content.url } });
|
||||
rowOfItem.push(i + 1);
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
setGeneratedQRs([]);
|
||||
setFailedRows(failures);
|
||||
setStep('complete');
|
||||
showToast('None of the rows could be turned into a dynamic QR code.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length > remainingDynamic) {
|
||||
showToast(
|
||||
`Only ${remainingDynamic} dynamic slots left. The rest are listed after the run.`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
// One request for the whole batch. Row-by-row POSTs ran straight into the
|
||||
// per-minute create limit, so most of a large upload silently 429'd.
|
||||
const res = await fetchWithCsrf('/api/qrs/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
isStatic: false,
|
||||
style: presetStyleToQrStyle(activeStyle()),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
showToast(err?.message || err?.error || 'Could not create the QR codes.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { created, failed } = (await res.json()) as {
|
||||
created: { row: number; title: string; slug: string }[];
|
||||
failed: { row: number; title: string; reason: string }[];
|
||||
};
|
||||
|
||||
failed.forEach((f) => {
|
||||
failures.push({ ...f, row: rowOfItem[f.row - 1] ?? f.row });
|
||||
});
|
||||
|
||||
const style = activeStyle();
|
||||
const results: GeneratedQR[] = created.map((qr) => {
|
||||
const redirectUrl = `${window.location.origin}/r/${qr.slug}`;
|
||||
return {
|
||||
title: qr.title,
|
||||
content: items[qr.row - 1]?.content.url ?? '',
|
||||
svg: renderStyledQRSvg(redirectUrl, style, 300),
|
||||
slug: qr.slug,
|
||||
redirectUrl,
|
||||
};
|
||||
});
|
||||
|
||||
failures.sort((a, b) => a.row - b.row);
|
||||
|
||||
setGeneratedQRs(results);
|
||||
setFailedRows(failures);
|
||||
// Dynamic codes exist in the dashboard the moment they are generated.
|
||||
setSavedToDashboard(true);
|
||||
await refreshQuota();
|
||||
setStep('complete');
|
||||
|
||||
@@ -372,37 +402,63 @@ export default function BulkCreationPage() {
|
||||
};
|
||||
|
||||
const saveQRCodesToDatabase = async () => {
|
||||
if (isDynamic) return; // dynamic codes are already saved during generation
|
||||
// Dynamic codes are written during generation, and a second press would
|
||||
// duplicate a static batch. Either way there is nothing left to save.
|
||||
if (savedToDashboard) return;
|
||||
if (generatedQRs.length === 0) {
|
||||
showToast('There are no QR codes to save.', 'error');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const qrCodesToSave = generatedQRs.map((qr) => ({
|
||||
title: qr.title,
|
||||
isStatic: true, // This tells the API it's a static QR code
|
||||
contentType: 'URL',
|
||||
content: { url: qr.content }, // Content needs to be an object with url property
|
||||
status: 'ACTIVE',
|
||||
}));
|
||||
const items = generatedQRs.map((qr) => {
|
||||
const detected = qr.detected ?? detectBulkContent(qr.content);
|
||||
return {
|
||||
title: qr.title,
|
||||
contentType: detected.contentType,
|
||||
content: detected.content,
|
||||
};
|
||||
});
|
||||
|
||||
// Save each QR code to the database
|
||||
const savePromises = qrCodesToSave.map((qr) =>
|
||||
fetchWithCsrf('/api/qrs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(qr),
|
||||
})
|
||||
);
|
||||
// The design goes with them. Without this the batch was previewed in the
|
||||
// user's own branding and then stored as plain black and white - the code
|
||||
// on screen and the code in the dashboard were not the same picture.
|
||||
const res = await fetchWithCsrf('/api/qrs/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items,
|
||||
isStatic: true,
|
||||
style: presetStyleToQrStyle(activeStyle()),
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await Promise.all(savePromises);
|
||||
const failedCount = results.filter((r) => !r.ok).length;
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
showToast(err?.message || err?.error || 'Failed to save QR codes', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedCount === 0) {
|
||||
showToast(`Successfully saved ${qrCodesToSave.length} QR codes!`, 'success');
|
||||
// Redirect to dashboard after 1 second
|
||||
const { created, failed } = (await res.json()) as {
|
||||
created: unknown[];
|
||||
failed: { row: number; title: string; reason: string }[];
|
||||
};
|
||||
|
||||
setSavedToDashboard(true);
|
||||
|
||||
if (failed.length === 0) {
|
||||
showToast(`Successfully saved ${created.length} QR codes!`, 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard';
|
||||
}, 1000);
|
||||
} else {
|
||||
showToast(`Saved ${qrCodesToSave.length - failedCount} QR codes, ${failedCount} failed`, 'warning');
|
||||
// Named rows, not a count. "12 failed" out of a print batch is not
|
||||
// something anyone can act on.
|
||||
setFailedRows(failed);
|
||||
showToast(
|
||||
`Saved ${created.length} QR codes. ${failed.length} could not be saved - see the list below.`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving QR codes:', error);
|
||||
@@ -437,6 +493,21 @@ export default function BulkCreationPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Nothing is known about the plan until the request comes back. Rendering the
|
||||
// paywall in the meantime told paying customers they had not paid.
|
||||
if (!planLoaded) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto animate-pulse">
|
||||
<div className="mb-8 space-y-3">
|
||||
<div className="h-9 w-64 rounded-lg bg-gray-200" />
|
||||
<div className="h-5 w-96 rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="h-24 rounded-xl bg-gray-100" />
|
||||
<div className="mt-6 h-64 rounded-xl bg-gray-100" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show upgrade prompt if not Business or Enterprise plan
|
||||
if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') {
|
||||
return (
|
||||
@@ -473,26 +544,26 @@ export default function BulkCreationPage() {
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
|
||||
|
||||
{/* Apply a saved design to the whole batch. */}
|
||||
{presets.length > 0 && (
|
||||
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Design preset
|
||||
</label>
|
||||
<Select
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Plain black and white' },
|
||||
...presets.map((p) => ({ value: p.id, label: p.name })),
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Applied to every code in this upload, so the whole batch matches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply a saved design to the whole batch. */}
|
||||
{presets.length > 0 && (
|
||||
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Design preset
|
||||
</label>
|
||||
<Select
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Plain black and white' },
|
||||
...presets.map((p) => ({ value: p.id, label: p.name })),
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Applied to every code in this upload, so the whole batch matches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Static / Dynamic Toggle */}
|
||||
<div className="mt-4 flex items-center gap-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<span className="text-sm font-medium text-gray-700">QR Code Type:</span>
|
||||
@@ -551,11 +622,25 @@ export default function BulkCreationPage() {
|
||||
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{/* This banner used to say "static only", which stopped being true
|
||||
when the dynamic option was added. It now describes whichever
|
||||
mode is actually selected above. */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1">Static QR Codes Only</h3>
|
||||
<h3 className="font-semibold text-blue-900 mb-1">
|
||||
{isDynamic ? 'Dynamic QR Codes' : 'Static QR Codes'}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
|
||||
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
|
||||
{isDynamic ? (
|
||||
<>
|
||||
Each code becomes a <strong>trackable short link</strong> you can re-point later.
|
||||
The content column must hold a web address, and each code uses one dynamic slot.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
|
||||
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -827,22 +912,37 @@ export default function BulkCreationPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.slice(0, 5).map((row: any, index) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="py-3 px-4">
|
||||
<QRCodeSVG
|
||||
value={row[mapping.content] || 'https://example.com'}
|
||||
size={40}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{row[mapping.title] || 'Untitled'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{(row[mapping.content] || '').substring(0, 50)}...
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data.slice(0, 5).map((row: any, index) => {
|
||||
// Same detection and same renderer as the real run, so this
|
||||
// table is a preview rather than a lookalike.
|
||||
const detected = detectBulkContent(row[mapping.content]);
|
||||
const raw = cellToString(row[mapping.content]);
|
||||
return (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="py-3 px-4">
|
||||
<div
|
||||
className="h-10 w-10"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderStyledQRSvg(
|
||||
detected.qrValue || 'https://example.com',
|
||||
activeStyle(),
|
||||
40
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{cellToString(row[mapping.title]) || 'Untitled'}
|
||||
<span className="ml-2 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
{detected.contentType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-900">
|
||||
{raw.length > 50 ? `${raw.substring(0, 50)}...` : raw}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -862,7 +962,7 @@ export default function BulkCreationPage() {
|
||||
loading={loading}
|
||||
>
|
||||
{isDynamic
|
||||
? `Generate ${Math.min(data.length, remainingDynamic)} Dynamic QR Codes`
|
||||
? `Generate ${Math.max(0, Math.min(data.length, remainingDynamic))} Dynamic QR Codes`
|
||||
: `Generate ${data.length} Static QR Codes`}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -887,7 +987,9 @@ export default function BulkCreationPage() {
|
||||
<p className="text-gray-600 mb-8">
|
||||
{failedRows.length > 0
|
||||
? 'The rows below could not be added. Nothing was silently dropped - here is exactly what is missing.'
|
||||
: `${generatedQRs.length} ${isDynamic ? 'dynamic' : 'static'} QR codes, ready to download.`}
|
||||
: savedToDashboard
|
||||
? `${generatedQRs.length} ${isDynamic ? 'dynamic' : 'static'} QR codes, saved to your dashboard and ready to download.`
|
||||
: `${generatedQRs.length} static QR codes, ready to download. Save them to keep them in your dashboard.`}
|
||||
</p>
|
||||
|
||||
{failedRows.length > 0 && (
|
||||
@@ -959,6 +1061,10 @@ export default function BulkCreationPage() {
|
||||
setData([]);
|
||||
setMapping({});
|
||||
setGeneratedQRs([]);
|
||||
// Left over from the previous run, the failure list reappeared
|
||||
// on top of the next upload as if it belonged to it.
|
||||
setFailedRows([]);
|
||||
setSavedToDashboard(false);
|
||||
}}>
|
||||
Create More
|
||||
</Button>
|
||||
@@ -968,8 +1074,21 @@ export default function BulkCreationPage() {
|
||||
</svg>
|
||||
Download All as ZIP
|
||||
</Button>
|
||||
{!isDynamic && (
|
||||
<Button onClick={saveQRCodesToDatabase} loading={loading}>
|
||||
{/* There is always an action here. The Save button used to be
|
||||
hidden entirely for dynamic batches, which left the final
|
||||
screen with no way forward at all - the codes were in the
|
||||
dashboard, but nothing on screen said so. */}
|
||||
{savedToDashboard ? (
|
||||
<Link href="/dashboard">
|
||||
<Button>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Saved - View in Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button onClick={saveQRCodesToDatabase} loading={loading} disabled={generatedQRs.length === 0}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import StyledQRCode from '@/components/generator/StyledQRCode';
|
||||
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
|
||||
import {
|
||||
ModuleShape,
|
||||
EyeFrameShape,
|
||||
EyeBallShape,
|
||||
PRO_MODULE_SHAPES,
|
||||
BUSINESS_MODULE_SHAPES,
|
||||
LOW_COVERAGE_SHAPES,
|
||||
MODULE_SHAPE_LABELS,
|
||||
EYE_FRAME_LABELS,
|
||||
EYE_BALL_LABELS,
|
||||
import StyledQRCode from '@/components/generator/StyledQRCode';
|
||||
import { renderStyledQRSvg } from '@/lib/render-qr-svg';
|
||||
import {
|
||||
ModuleShape,
|
||||
EyeFrameShape,
|
||||
EyeBallShape,
|
||||
PRO_MODULE_SHAPES,
|
||||
BUSINESS_MODULE_SHAPES,
|
||||
LOW_COVERAGE_SHAPES,
|
||||
MODULE_SHAPE_LABELS,
|
||||
EYE_FRAME_LABELS,
|
||||
EYE_BALL_LABELS,
|
||||
} from '@/lib/qr-shapes';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
@@ -141,13 +141,13 @@ export default function CreatePage() {
|
||||
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
|
||||
const [cornerStyle, setCornerStyle] = useState('square');
|
||||
const [size, setSize] = useState(200);
|
||||
const [frameType, setFrameType] = useState('none');
|
||||
const [moduleShape, setModuleShape] = useState<ModuleShape>('square');
|
||||
const [eyeFrameShape, setEyeFrameShape] = useState<EyeFrameShape>('square');
|
||||
const [eyeBallShape, setEyeBallShape] = useState<EyeBallShape>('square');
|
||||
const [gradientMode, setGradientMode] = useState<'none' | 'linear' | 'radial'>('none');
|
||||
const [gradientTo, setGradientTo] = useState('#7C3AED');
|
||||
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
|
||||
const [frameType, setFrameType] = useState('none');
|
||||
const [moduleShape, setModuleShape] = useState<ModuleShape>('square');
|
||||
const [eyeFrameShape, setEyeFrameShape] = useState<EyeFrameShape>('square');
|
||||
const [eyeBallShape, setEyeBallShape] = useState<EyeBallShape>('square');
|
||||
const [gradientMode, setGradientMode] = useState<'none' | 'linear' | 'radial'>('none');
|
||||
const [gradientTo, setGradientTo] = useState('#7C3AED');
|
||||
const [presets, setPresets] = useState<{ id: string; name: string; style: any }[]>([]);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
// Upgrade modal. Replaces the old redirect to /pricing, which destroyed the
|
||||
@@ -503,75 +503,75 @@ export default function CreatePage() {
|
||||
);
|
||||
};
|
||||
|
||||
// The logo belongs in the preset. For an agency, "client A looks the same on
|
||||
// all 500 codes" is mostly about the mark in the middle - a preset that
|
||||
// carries the colours but drops the logo solves the smaller half of the job.
|
||||
const currentDesign = () => ({
|
||||
foregroundColor,
|
||||
backgroundColor,
|
||||
moduleShape,
|
||||
eyeFrameShape,
|
||||
eyeBallShape,
|
||||
gradientMode,
|
||||
gradientTo,
|
||||
frameType,
|
||||
logoUrl: canUseLogo ? logoUrl : '',
|
||||
logoSize,
|
||||
});
|
||||
|
||||
const applyDesign = (style: any) => {
|
||||
if (!style) return;
|
||||
if (style.foregroundColor) setForegroundColor(style.foregroundColor);
|
||||
if (style.backgroundColor) setBackgroundColor(style.backgroundColor);
|
||||
if (style.moduleShape) setModuleShape(style.moduleShape);
|
||||
if (style.eyeFrameShape) setEyeFrameShape(style.eyeFrameShape);
|
||||
if (style.eyeBallShape) setEyeBallShape(style.eyeBallShape);
|
||||
if (style.gradientMode) setGradientMode(style.gradientMode);
|
||||
if (style.gradientTo) setGradientTo(style.gradientTo);
|
||||
if (style.frameType) setFrameType(style.frameType);
|
||||
if (typeof style.logoUrl === 'string' && canUseLogo) setLogoUrl(style.logoUrl);
|
||||
if (style.logoSize) setLogoSize(style.logoSize);
|
||||
};
|
||||
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/design-presets');
|
||||
if (res.ok) setPresets(await res.json());
|
||||
} catch {
|
||||
// presets are a convenience, never block the page on them
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (canUseFullDesign) void loadPresets();
|
||||
}, [canUseFullDesign]);
|
||||
|
||||
const savePreset = async () => {
|
||||
const name = presetName.trim();
|
||||
if (!name) {
|
||||
showToast('Give the preset a name first.', 'error');
|
||||
return;
|
||||
}
|
||||
const res = await fetchWithCsrf('/api/design-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, style: currentDesign() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setPresetName('');
|
||||
await loadPresets();
|
||||
trackEvent('design_preset_saved', { plan: userPlan });
|
||||
showToast(`Preset "${name}" saved.`, 'success');
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
showToast(err?.message || 'Could not save the preset.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deletePreset = async (id: string) => {
|
||||
const res = await fetchWithCsrf(`/api/design-presets?id=${id}`, { method: 'DELETE' });
|
||||
if (res.ok) await loadPresets();
|
||||
};
|
||||
|
||||
// The logo belongs in the preset. For an agency, "client A looks the same on
|
||||
// all 500 codes" is mostly about the mark in the middle - a preset that
|
||||
// carries the colours but drops the logo solves the smaller half of the job.
|
||||
const currentDesign = () => ({
|
||||
foregroundColor,
|
||||
backgroundColor,
|
||||
moduleShape,
|
||||
eyeFrameShape,
|
||||
eyeBallShape,
|
||||
gradientMode,
|
||||
gradientTo,
|
||||
frameType,
|
||||
logoUrl: canUseLogo ? logoUrl : '',
|
||||
logoSize,
|
||||
});
|
||||
|
||||
const applyDesign = (style: any) => {
|
||||
if (!style) return;
|
||||
if (style.foregroundColor) setForegroundColor(style.foregroundColor);
|
||||
if (style.backgroundColor) setBackgroundColor(style.backgroundColor);
|
||||
if (style.moduleShape) setModuleShape(style.moduleShape);
|
||||
if (style.eyeFrameShape) setEyeFrameShape(style.eyeFrameShape);
|
||||
if (style.eyeBallShape) setEyeBallShape(style.eyeBallShape);
|
||||
if (style.gradientMode) setGradientMode(style.gradientMode);
|
||||
if (style.gradientTo) setGradientTo(style.gradientTo);
|
||||
if (style.frameType) setFrameType(style.frameType);
|
||||
if (typeof style.logoUrl === 'string' && canUseLogo) setLogoUrl(style.logoUrl);
|
||||
if (style.logoSize) setLogoSize(style.logoSize);
|
||||
};
|
||||
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/design-presets');
|
||||
if (res.ok) setPresets(await res.json());
|
||||
} catch {
|
||||
// presets are a convenience, never block the page on them
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (canUseFullDesign) void loadPresets();
|
||||
}, [canUseFullDesign]);
|
||||
|
||||
const savePreset = async () => {
|
||||
const name = presetName.trim();
|
||||
if (!name) {
|
||||
showToast('Give the preset a name first.', 'error');
|
||||
return;
|
||||
}
|
||||
const res = await fetchWithCsrf('/api/design-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, style: currentDesign() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setPresetName('');
|
||||
await loadPresets();
|
||||
trackEvent('design_preset_saved', { plan: userPlan });
|
||||
showToast(`Preset "${name}" saved.`, 'success');
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
showToast(err?.message || 'Could not save the preset.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deletePreset = async (id: string) => {
|
||||
const res = await fetchWithCsrf(`/api/design-presets?id=${id}`, { method: 'DELETE' });
|
||||
if (res.ok) await loadPresets();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -1050,7 +1050,7 @@ export default function CreatePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('create.subtitle')}</p>
|
||||
@@ -1165,220 +1165,220 @@ export default function CreatePage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
||||
{/* Module shape. Colors are free; shapes are the Pro driver that
|
||||
replaced them. Locked options stay clickable so the preview
|
||||
shows what is being bought before anyone pays for it. */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">Module shape</label>
|
||||
{!canUseShapes && <Badge variant="info">Pro</Badge>}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{([...PRO_MODULE_SHAPES, ...BUSINESS_MODULE_SHAPES] as ModuleShape[]).map((shape) => {
|
||||
const isBusinessOnly = BUSINESS_MODULE_SHAPES.includes(shape);
|
||||
const allowed = shape === 'square'
|
||||
|| (isBusinessOnly ? canUseFullDesign : canUseShapes);
|
||||
return (
|
||||
<button
|
||||
key={shape}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModuleShape(shape);
|
||||
if (!allowed) {
|
||||
trackEvent('upgrade_prompt_shown', {
|
||||
reason: 'shapes',
|
||||
shape,
|
||||
plan: userPlan,
|
||||
});
|
||||
setUpgradeReason('shapes');
|
||||
setUpgradeOpen(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border p-2 text-xs transition-colors',
|
||||
moduleShape === shape
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300',
|
||||
!allowed && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">{MODULE_SHAPE_LABELS[shape]}</span>
|
||||
{!allowed && (
|
||||
<span className="mt-0.5 block text-[10px] text-gray-400">
|
||||
{isBusinessOnly ? 'Business' : 'Pro'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eye styles. Only the combinations that survived decoding are
|
||||
offered - see the note in lib/qr-shapes.ts. */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Eye frame"
|
||||
value={eyeFrameShape}
|
||||
onChange={(e) => {
|
||||
if (!canUseShapes) {
|
||||
setUpgradeReason('shapes');
|
||||
setUpgradeOpen(true);
|
||||
return;
|
||||
}
|
||||
setEyeFrameShape(e.target.value as EyeFrameShape);
|
||||
}}
|
||||
options={Object.entries(EYE_FRAME_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="Eye centre"
|
||||
value={eyeBallShape}
|
||||
onChange={(e) => {
|
||||
if (!canUseShapes) {
|
||||
setUpgradeReason('shapes');
|
||||
setUpgradeOpen(true);
|
||||
return;
|
||||
}
|
||||
setEyeBallShape(e.target.value as EyeBallShape);
|
||||
}}
|
||||
options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gradient. Business only - the renderer takes it as a prop, so
|
||||
this is purely a gating and input concern. */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">Gradient</label>
|
||||
{!canUseFullDesign && <Badge variant="info">Business</Badge>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['none', 'linear', 'radial'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (mode !== 'none' && !canUseFullDesign) {
|
||||
trackEvent('upgrade_prompt_shown', { reason: 'shapes', feature: 'gradient', plan: userPlan });
|
||||
setUpgradeReason('shapes');
|
||||
setUpgradeOpen(true);
|
||||
return;
|
||||
}
|
||||
setGradientMode(mode);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border p-2 text-xs capitalize transition-colors',
|
||||
gradientMode === mode
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300',
|
||||
mode !== 'none' && !canUseFullDesign && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{mode === 'none' ? 'Solid' : mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{gradientMode !== 'none' && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<label className="text-sm text-gray-700">Second colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={gradientTo}
|
||||
onChange={(e) => setGradientTo(e.target.value)}
|
||||
className="h-10 w-12 rounded border border-gray-300"
|
||||
/>
|
||||
<Input
|
||||
value={gradientTo}
|
||||
onChange={(e) => setGradientTo(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scannability. Says what was changed and why, rather than
|
||||
silently raising the error correction behind the user. */}
|
||||
{(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||
<p className="text-sm text-amber-900">
|
||||
{logoUrl
|
||||
? 'Error correction is set to H because this code carries a logo.'
|
||||
: `"${MODULE_SHAPE_LABELS[moduleShape]}" fills less of each module, so error correction has been raised.`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-amber-800">
|
||||
Print it at 2 x 2 cm or larger, and scan it once with your own
|
||||
phone before you send it to the printer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved presets. Business only. Repeatability is the actual
|
||||
product here - the star shape is not what an agency buys. */}
|
||||
{canUseFullDesign && (
|
||||
<div className="rounded-lg border border-gray-200 p-3">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Design presets
|
||||
</label>
|
||||
{presets.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{presets.map((preset) => (
|
||||
<span
|
||||
key={preset.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 py-1 pl-3 pr-1 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyDesign(preset.style)}
|
||||
className="text-gray-700 hover:text-primary-700"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deletePreset(preset.id)}
|
||||
className="px-1 text-gray-400 hover:text-red-600"
|
||||
aria-label={`Delete preset ${preset.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="Client A"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={savePreset}>
|
||||
Save current design
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Saving under an existing name overwrites it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module shape. Colors are free; shapes are the Pro driver that
|
||||
replaced them. Locked options stay clickable so the preview
|
||||
shows what is being bought before anyone pays for it. */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">Module shape</label>
|
||||
{!canUseShapes && <Badge variant="info">Pro</Badge>}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
|
||||
{([...PRO_MODULE_SHAPES, ...BUSINESS_MODULE_SHAPES] as ModuleShape[]).map((shape) => {
|
||||
const isBusinessOnly = BUSINESS_MODULE_SHAPES.includes(shape);
|
||||
const allowed = shape === 'square'
|
||||
|| (isBusinessOnly ? canUseFullDesign : canUseShapes);
|
||||
return (
|
||||
<button
|
||||
key={shape}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModuleShape(shape);
|
||||
if (!allowed) {
|
||||
trackEvent('upgrade_prompt_shown', {
|
||||
reason: 'shapes',
|
||||
shape,
|
||||
plan: userPlan,
|
||||
});
|
||||
setUpgradeReason(isBusinessOnly ? 'business-shapes' : 'shapes');
|
||||
setUpgradeOpen(true);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border p-2 text-xs transition-colors lg:p-3 lg:text-sm',
|
||||
moduleShape === shape
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300',
|
||||
!allowed && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">{MODULE_SHAPE_LABELS[shape]}</span>
|
||||
{!allowed && (
|
||||
<span className="mt-0.5 block text-[10px] text-gray-400">
|
||||
{isBusinessOnly ? 'Business' : 'Pro'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eye styles. Only the combinations that survived decoding are
|
||||
offered - see the note in lib/qr-shapes.ts. */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Eye frame"
|
||||
value={eyeFrameShape}
|
||||
onChange={(e) => {
|
||||
if (!canUseShapes) {
|
||||
setUpgradeReason('shapes');
|
||||
setUpgradeOpen(true);
|
||||
return;
|
||||
}
|
||||
setEyeFrameShape(e.target.value as EyeFrameShape);
|
||||
}}
|
||||
options={Object.entries(EYE_FRAME_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="Eye centre"
|
||||
value={eyeBallShape}
|
||||
onChange={(e) => {
|
||||
if (!canUseShapes) {
|
||||
setUpgradeReason('shapes');
|
||||
setUpgradeOpen(true);
|
||||
return;
|
||||
}
|
||||
setEyeBallShape(e.target.value as EyeBallShape);
|
||||
}}
|
||||
options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gradient. Business only - the renderer takes it as a prop, so
|
||||
this is purely a gating and input concern. */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">Gradient</label>
|
||||
{!canUseFullDesign && <Badge variant="info">Business</Badge>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['none', 'linear', 'radial'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (mode !== 'none' && !canUseFullDesign) {
|
||||
trackEvent('upgrade_prompt_shown', { reason: 'business-shapes', feature: 'gradient', plan: userPlan });
|
||||
setUpgradeReason('business-shapes');
|
||||
setUpgradeOpen(true);
|
||||
return;
|
||||
}
|
||||
setGradientMode(mode);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border p-2 text-xs capitalize transition-colors',
|
||||
gradientMode === mode
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 text-gray-600 hover:border-gray-300',
|
||||
mode !== 'none' && !canUseFullDesign && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{mode === 'none' ? 'Solid' : mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{gradientMode !== 'none' && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<label className="text-sm text-gray-700">Second colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={gradientTo}
|
||||
onChange={(e) => setGradientTo(e.target.value)}
|
||||
className="h-10 w-12 rounded border border-gray-300"
|
||||
/>
|
||||
<Input
|
||||
value={gradientTo}
|
||||
onChange={(e) => setGradientTo(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scannability. Says what was changed and why, rather than
|
||||
silently raising the error correction behind the user. */}
|
||||
{(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||
<p className="text-sm text-amber-900">
|
||||
{logoUrl
|
||||
? 'Error correction is set to H because this code carries a logo.'
|
||||
: `"${MODULE_SHAPE_LABELS[moduleShape]}" fills less of each module, so error correction has been raised.`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-amber-800">
|
||||
Print it at 2 x 2 cm or larger, and scan it once with your own
|
||||
phone before you send it to the printer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved presets. Business only. Repeatability is the actual
|
||||
product here - the star shape is not what an agency buys. */}
|
||||
{canUseFullDesign && (
|
||||
<div className="rounded-lg border border-gray-200 p-3">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Design presets
|
||||
</label>
|
||||
{presets.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{presets.map((preset) => (
|
||||
<span
|
||||
key={preset.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 py-1 pl-3 pr-1 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyDesign(preset.style)}
|
||||
className="text-gray-700 hover:text-primary-700"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deletePreset(preset.id)}
|
||||
className="px-1 text-gray-400 hover:text-red-600"
|
||||
aria-label={`Delete preset ${preset.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="Client A"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={savePreset}>
|
||||
Save current design
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Saving under an existing name overwrites it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frame Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Frame</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 lg:gap-3">
|
||||
{frameOptions.map((frame: { id: string; label: string }) => (
|
||||
<button
|
||||
key={frame.id}
|
||||
type="button"
|
||||
onClick={() => setFrameType(frame.id)}
|
||||
className={cn(
|
||||
"py-2 px-3 rounded-lg text-sm font-medium transition-all border",
|
||||
"py-2 px-3 rounded-lg text-sm font-medium transition-all border lg:py-3",
|
||||
frameType === frame.id
|
||||
? "bg-slate-900 text-white border-slate-900"
|
||||
: "bg-gray-50 text-gray-600 border-gray-200 hover:border-gray-300"
|
||||
@@ -1573,7 +1573,7 @@ export default function CreatePage() {
|
||||
{/* WRAPPER FOR REF AND FRAME */}
|
||||
<div
|
||||
ref={qrRef}
|
||||
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4"
|
||||
className="relative flex w-full min-w-0 max-w-full flex-col items-center justify-center rounded-xl bg-white p-3 transition-all duration-300 sm:p-4 lg:p-6"
|
||||
style={{
|
||||
minHeight: '220px',
|
||||
}}
|
||||
@@ -1679,6 +1679,7 @@ export default function CreatePage() {
|
||||
<UpgradeModal
|
||||
open={upgradeOpen}
|
||||
reason={upgradeReason}
|
||||
plan={userPlan}
|
||||
currentCount={limitInfo?.current}
|
||||
limit={limitInfo?.limit}
|
||||
activeCodes={activeCodes}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { BillingToggle } from '@/components/ui/BillingToggle';
|
||||
import { trackEvent } from '@/components/PostHogProvider';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '@/lib/plans';
|
||||
|
||||
type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
|
||||
type BillingInterval = 'month' | 'year';
|
||||
|
||||
/**
|
||||
* In-app upgrade page.
|
||||
@@ -31,7 +33,8 @@ type PlanKey = 'FREE' | 'PRO' | 'BUSINESS';
|
||||
const PLANS: {
|
||||
key: PlanKey;
|
||||
name: string;
|
||||
price: string;
|
||||
priceMonth: string;
|
||||
priceYear: string;
|
||||
period: string;
|
||||
caption: string;
|
||||
features: string[];
|
||||
@@ -40,7 +43,8 @@ const PLANS: {
|
||||
{
|
||||
key: 'FREE',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
priceMonth: '€0',
|
||||
priceYear: '€0',
|
||||
period: 'forever',
|
||||
caption: 'Enough to prove the idea on one or two placements.',
|
||||
features: [
|
||||
@@ -54,7 +58,8 @@ const PLANS: {
|
||||
{
|
||||
key: 'PRO',
|
||||
name: 'Pro',
|
||||
price: '€9',
|
||||
priceMonth: '€9',
|
||||
priceYear: '€90',
|
||||
period: 'per month',
|
||||
popular: true,
|
||||
caption: 'When one campaign is no longer the only campaign.',
|
||||
@@ -69,13 +74,14 @@ const PLANS: {
|
||||
{
|
||||
key: 'BUSINESS',
|
||||
name: 'Business',
|
||||
price: '€29',
|
||||
priceMonth: '€29',
|
||||
priceYear: '€290',
|
||||
period: 'per month',
|
||||
caption: 'When codes are produced in batches, not one at a time.',
|
||||
features: [
|
||||
`${BUSINESS_DYNAMIC_QR_LIMIT} dynamic QR codes`,
|
||||
'Bulk creation: 1,000 static or 500 dynamic per upload',
|
||||
'Full designer: 11 module shapes and colour gradients',
|
||||
'Full designer: 11 module shapes and colour gradients',
|
||||
'Saved design presets, applied to a whole bulk upload',
|
||||
'Priority email support',
|
||||
'Everything in Pro',
|
||||
@@ -94,6 +100,8 @@ const REASON_HEADLINES: Record<string, string> = {
|
||||
export default function UpgradePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [currentPlan, setCurrentPlan] = useState<PlanKey>('FREE');
|
||||
const [currentInterval, setCurrentInterval] = useState<BillingInterval | null>(null);
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingInterval>('month');
|
||||
const [loadingPlan, setLoadingPlan] = useState<PlanKey | null>(null);
|
||||
|
||||
const reason = searchParams.get('reason');
|
||||
@@ -102,7 +110,10 @@ export default function UpgradePage() {
|
||||
useEffect(() => {
|
||||
fetch('/api/user/plan')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => d?.plan && setCurrentPlan(d.plan))
|
||||
.then((d) => {
|
||||
if (d?.plan) setCurrentPlan(d.plan);
|
||||
if (d?.interval) setCurrentInterval(d.interval);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -115,7 +126,12 @@ export default function UpgradePage() {
|
||||
const handleUpgrade = async (plan: PlanKey) => {
|
||||
if (plan === 'FREE') return;
|
||||
setLoadingPlan(plan);
|
||||
trackEvent('upgrade_clicked', { plan, source: 'in_app_upgrade', reason });
|
||||
trackEvent('upgrade_clicked', {
|
||||
plan,
|
||||
billing_interval: billingPeriod,
|
||||
source: 'in_app_upgrade',
|
||||
reason,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/stripe/create-checkout-session', {
|
||||
@@ -123,7 +139,7 @@ export default function UpgradePage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
billingInterval: 'month',
|
||||
billingInterval: billingPeriod,
|
||||
returnPath: returnTo && returnTo.startsWith('/') ? returnTo : '/dashboard',
|
||||
}),
|
||||
});
|
||||
@@ -141,6 +157,34 @@ export default function UpgradePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDowngrade = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to cancel your paid plan? You will keep premium features until the end of your current billing period.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setLoadingPlan('FREE');
|
||||
trackEvent('downgrade_clicked', { source: 'in_app_upgrade', current_plan: currentPlan });
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/stripe/cancel-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to cancel subscription.');
|
||||
}
|
||||
|
||||
showToast('Subscription will end at the end of your current billing period.', 'success');
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (err: any) {
|
||||
showToast(err?.message || 'Could not downgrade. Please try again.', 'error');
|
||||
setLoadingPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div className="mb-10 max-w-2xl">
|
||||
@@ -156,9 +200,25 @@ export default function UpgradePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex justify-center">
|
||||
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const isCurrent = plan.key === currentPlan;
|
||||
const isCurrent =
|
||||
plan.key === currentPlan &&
|
||||
(plan.key === 'FREE' || currentInterval === null || currentInterval === billingPeriod);
|
||||
const hasPlanDifferentInterval =
|
||||
plan.key !== 'FREE' &&
|
||||
plan.key === currentPlan &&
|
||||
currentInterval !== null &&
|
||||
currentInterval !== billingPeriod;
|
||||
const price = billingPeriod === 'month' ? plan.priceMonth : plan.priceYear;
|
||||
const period =
|
||||
plan.key === 'FREE' ? plan.period : billingPeriod === 'month' ? 'per month' : 'per year';
|
||||
const isDowngrade = plan.key === 'FREE' && currentPlan !== 'FREE';
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.key}
|
||||
@@ -172,9 +232,14 @@ export default function UpgradePage() {
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-slate-900">{plan.price}</span>
|
||||
<span className="text-sm text-slate-500">{plan.period}</span>
|
||||
<span className="text-4xl font-bold text-slate-900">{price}</span>
|
||||
<span className="text-sm text-slate-500">{period}</span>
|
||||
</div>
|
||||
{plan.key !== 'FREE' && billingPeriod === 'year' && (
|
||||
<Badge variant="success" className="mb-4 w-fit">
|
||||
Save 16%
|
||||
</Badge>
|
||||
)}
|
||||
<p className="mb-6 text-sm text-slate-600">{plan.caption}</p>
|
||||
|
||||
<ul className="mb-8 space-y-3">
|
||||
@@ -191,6 +256,21 @@ export default function UpgradePage() {
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Current plan
|
||||
</Button>
|
||||
) : isDowngrade ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={loadingPlan !== null}
|
||||
onClick={handleDowngrade}
|
||||
>
|
||||
{loadingPlan === 'FREE' ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Cancelling...
|
||||
</span>
|
||||
) : (
|
||||
'Downgrade to Free'
|
||||
)}
|
||||
</Button>
|
||||
) : plan.key === 'FREE' ? (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
Included
|
||||
@@ -206,6 +286,8 @@ export default function UpgradePage() {
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
|
||||
</span>
|
||||
) : hasPlanDifferentInterval ? (
|
||||
`Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
|
||||
) : (
|
||||
`Upgrade to ${plan.name}`
|
||||
)}
|
||||
|
||||
204
src/app/(main)/api/qrs/bulk/route.ts
Normal file
204
src/app/(main)/api/qrs/bulk/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||
import { buildVcard } from '@/lib/bulk-content';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -173,15 +174,10 @@ export async function POST(request: NextRequest) {
|
||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
// Shared with the bulk endpoint. Two copies of this template drifting
|
||||
// apart would mean the same contact encodes differently depending on
|
||||
// which route created it.
|
||||
qrContent = buildVcard(body.content);
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = body.content.latitude || 0;
|
||||
|
||||
Reference in New Issue
Block a user