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;
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { X, Loader2, Pause, Download, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export type UpgradeReason = 'limit' | 'logo' | 'shapes';
|
||||
export type UpgradeReason = 'limit' | 'logo' | 'shapes' | 'business-shapes';
|
||||
|
||||
export interface ActiveCodeSummary {
|
||||
id: string;
|
||||
@@ -15,6 +15,11 @@ export interface ActiveCodeSummary {
|
||||
interface UpgradeModalProps {
|
||||
open: boolean;
|
||||
reason: UpgradeReason;
|
||||
/** The user's current plan. Determines which plan is offered next
|
||||
* (Free -> Pro, Pro -> Business) and whether a paid CTA makes sense
|
||||
* at all (Business/Enterprise are already at or above the ceiling
|
||||
* a plain checkout button can sell). Defaults to 'FREE'. */
|
||||
plan?: string;
|
||||
/** How many dynamic codes the user already has. Only used for reason="limit". */
|
||||
currentCount?: number;
|
||||
/** The plan limit that was hit. Only used for reason="limit". */
|
||||
@@ -49,7 +54,7 @@ function ordinal(n: number): string {
|
||||
* hostage, and this is the one screen where that has to be demonstrated
|
||||
* rather than claimed.
|
||||
*/
|
||||
function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
|
||||
function getCopy(reason: UpgradeReason, plan: string, currentCount?: number, limit?: number) {
|
||||
if (reason === 'logo') {
|
||||
return {
|
||||
headline: 'Your logo belongs inside this code.',
|
||||
@@ -58,6 +63,7 @@ function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
|
||||
'Pro puts your logo in the center of every code you make, at the error-correction level that keeps it scannable.',
|
||||
cta: 'Add my logo - €9 / month',
|
||||
reassurance: 'Your current codes keep working exactly as they are.',
|
||||
ctaType: 'checkout' as const,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,24 +75,64 @@ function getCopy(reason: UpgradeReason, currentCount?: number, limit?: number) {
|
||||
'Pro unlocks four module shapes and your own eye styles. Colors stay free on every plan.',
|
||||
cta: 'Unlock shapes - €9 / month',
|
||||
reassurance: 'Your current codes keep working exactly as they are.',
|
||||
ctaType: 'checkout' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (reason === 'business-shapes') {
|
||||
return {
|
||||
headline: 'This shape needs Business.',
|
||||
body: 'Classy, diamond, hexagon, plus, mosaic, liquid and star modules are the shapes agencies and larger teams use to make a code feel fully custom, not just styled.',
|
||||
mechanism:
|
||||
'Business unlocks every module shape, plus white-label branding and priority support. Colors stay free on every plan.',
|
||||
cta: 'Unlock all shapes - €29 / month',
|
||||
reassurance: 'Your current codes keep working exactly as they are.',
|
||||
ctaType: 'checkout' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const next = (currentCount ?? 3) + 1;
|
||||
const cap = limit ?? 3;
|
||||
|
||||
// Free -> Pro is the default upsell. Pro -> Business and Business/Enterprise
|
||||
// "already at the top" need their own copy - otherwise a Pro user who fills
|
||||
// their 50 slots gets told to buy the Pro plan they already have.
|
||||
if (plan === 'BUSINESS' || plan === 'ENTERPRISE') {
|
||||
return {
|
||||
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
|
||||
body: `You are using all ${cap} dynamic codes on your Business plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
|
||||
mechanism: 'You are already on our highest self-serve plan. For a higher ceiling than 500 dynamic codes, contact us directly.',
|
||||
cta: 'Contact us about a higher limit',
|
||||
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
|
||||
ctaType: 'contact' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (plan === 'PRO') {
|
||||
return {
|
||||
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
|
||||
body: `You are using all ${cap} dynamic codes on your Pro plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
|
||||
mechanism: 'Business raises the ceiling to 500 dynamic codes and adds bulk creation and priority support.',
|
||||
cta: 'Save this code with Business - €29 / month',
|
||||
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
|
||||
ctaType: 'checkout' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headline: `Your ${ordinal(next)} code is finished. It just needs a slot.`,
|
||||
body: `You are using all ${cap} dynamic codes on your plan. This one is built and waiting - you can keep it, or free a slot from the codes you already have.`,
|
||||
mechanism: 'Pro raises the ceiling to 50 dynamic codes and adds device and location data for every scan.',
|
||||
cta: 'Save this code with Pro - €9 / month',
|
||||
reassurance: `Your ${cap} active codes keep running, whichever way you decide.`,
|
||||
ctaType: 'checkout' as const,
|
||||
};
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
open,
|
||||
reason,
|
||||
plan = 'FREE',
|
||||
currentCount,
|
||||
limit,
|
||||
activeCodes = [],
|
||||
@@ -119,7 +165,15 @@ export default function UpgradeModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const copy = getCopy(reason, currentCount, limit);
|
||||
const copy = getCopy(reason, plan, currentCount, limit);
|
||||
|
||||
// Which plan a checkout should sell depends on where the user already is,
|
||||
// not just why the modal opened. A Pro user hitting their code limit needs
|
||||
// Business, not another Pro subscription.
|
||||
const targetPlan =
|
||||
reason === 'business-shapes' || (reason === 'limit' && plan === 'PRO')
|
||||
? 'BUSINESS'
|
||||
: 'PRO';
|
||||
|
||||
const handleCheckout = async () => {
|
||||
setCheckoutLoading(true);
|
||||
@@ -135,7 +189,7 @@ export default function UpgradeModal({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plan: 'PRO',
|
||||
plan: targetPlan,
|
||||
billingInterval: 'month',
|
||||
returnPath: path,
|
||||
}),
|
||||
@@ -205,21 +259,30 @@ export default function UpgradeModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={checkoutLoading}
|
||||
className="h-12 w-full bg-primary-600 text-base font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
{checkoutLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{copy.cta} <ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{copy.ctaType === 'contact' ? (
|
||||
<a
|
||||
href="mailto:support@qrmaster.net?subject=Higher%20dynamic%20QR%20limit"
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary-600 text-base font-semibold text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
{copy.cta} <ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={checkoutLoading}
|
||||
className="h-12 w-full bg-primary-600 text-base font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
{checkoutLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Opening checkout...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{copy.cta} <ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{reason === 'limit' && onPauseCode && activeCodes.length > 0 && !showCodeList && (
|
||||
<button
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import Barcode from 'react-barcode';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import {
|
||||
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
|
||||
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
|
||||
} from '@/lib/revops';
|
||||
import StyledQRCode from '@/components/generator/StyledQRCode';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import {
|
||||
ONBOARDING_DOWNLOAD_COMPLETE_EVENT,
|
||||
ONBOARDING_DOWNLOAD_COMPLETE_KEY,
|
||||
} from '@/lib/revops';
|
||||
|
||||
function addBarcodeCaptionToSvg(svgElement: SVGElement, caption: string): string {
|
||||
const cloned = svgElement.cloneNode(true) as SVGElement;
|
||||
@@ -63,21 +63,21 @@ interface QRCodeCardProps {
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||
qr,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const markDownloadComplete = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1');
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
|
||||
};
|
||||
|
||||
// For dynamic QR codes, use the redirect URL for tracking
|
||||
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||
qr,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const markDownloadComplete = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(ONBOARDING_DOWNLOAD_COMPLETE_KEY, '1');
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_DOWNLOAD_COMPLETE_EVENT));
|
||||
};
|
||||
|
||||
// For dynamic QR codes, use the redirect URL for tracking
|
||||
// For static QR codes, use the direct URL from content
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
|
||||
|
||||
@@ -138,11 +138,11 @@ END:VCARD`;
|
||||
pixelRatio: 3,
|
||||
backgroundColor: '#ffffff' // White background for clean export
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
markDownloadComplete();
|
||||
const link = document.createElement('a');
|
||||
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
markDownloadComplete();
|
||||
} else {
|
||||
// For SVG, if no frame, export just the QR code SVG for vector quality
|
||||
// If frame exists, use toPng as fallback since HTML-to-SVG is complex
|
||||
@@ -153,11 +153,11 @@ END:VCARD`;
|
||||
pixelRatio: 3,
|
||||
backgroundColor: '#ffffff'
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
markDownloadComplete();
|
||||
const link = document.createElement('a');
|
||||
link.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
markDownloadComplete();
|
||||
} else {
|
||||
// No frame - export clean SVG from the svg wrapper
|
||||
const svgContainer = document.querySelector(`#qr-svg-${qr.id}`);
|
||||
@@ -185,12 +185,12 @@ END:VCARD`;
|
||||
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
markDownloadComplete();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
markDownloadComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,16 +200,16 @@ END:VCARD`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
|
||||
<CardContent className="p-4">
|
||||
<Card hover className="rounded-[24px] border-slate-200 p-0 shadow-none">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={qr.type === 'DYNAMIC' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700'}>
|
||||
{qr.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge className={qr.type === 'DYNAMIC' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-700'}>
|
||||
{qr.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
@@ -234,12 +234,12 @@ END:VCARD`;
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
|
||||
{/* Download wrapper - tightly wraps content */}
|
||||
<div
|
||||
id={`qr-download-${qr.id}`}
|
||||
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
|
||||
>
|
||||
<div className="mb-3 flex flex-col items-center justify-center rounded-[20px] bg-slate-50 p-4">
|
||||
{/* Download wrapper - tightly wraps content */}
|
||||
<div
|
||||
id={`qr-download-${qr.id}`}
|
||||
className="inline-flex flex-col items-center rounded-[20px] border border-slate-100 bg-white p-4"
|
||||
>
|
||||
{/* Frame Label */}
|
||||
{qr.style?.frameType && qr.style.frameType !== 'none' && (
|
||||
<div
|
||||
@@ -284,18 +284,31 @@ END:VCARD`;
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<QRCodeSVG
|
||||
/* Same renderer as the detail page and the bulk export.
|
||||
qrcode.react only knows two colours, so a code saved with a
|
||||
module shape, gradient or logo showed up here as a plain
|
||||
black grid - the design looked lost even when it was stored. */
|
||||
<StyledQRCode
|
||||
value={qrUrl}
|
||||
size={96}
|
||||
fgColor={qr.style?.foregroundColor || '#000000'}
|
||||
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
||||
level="H"
|
||||
imageSettings={qr.style?.imageSettings ? {
|
||||
src: qr.style.imageSettings.src,
|
||||
height: qr.style.imageSettings.height * (96 / 200),
|
||||
width: qr.style.imageSettings.width * (96 / 200),
|
||||
excavate: qr.style.imageSettings.excavate,
|
||||
} : undefined}
|
||||
moduleShape={qr.style?.moduleShape || 'square'}
|
||||
eyeFrameShape={qr.style?.eyeFrameShape || 'square'}
|
||||
eyeBallShape={qr.style?.eyeBallShape || 'square'}
|
||||
gradient={
|
||||
qr.style?.gradientMode && qr.style.gradientMode !== 'none'
|
||||
? {
|
||||
type: qr.style.gradientMode,
|
||||
from: qr.style.foregroundColor || '#000000',
|
||||
to: qr.style.gradientTo || '#000000',
|
||||
}
|
||||
: null
|
||||
}
|
||||
errorCorrection="H"
|
||||
logoUrl={qr.style?.imageSettings?.src}
|
||||
logoScale={(qr.style?.imageSettings?.width ?? 24) / 200}
|
||||
margin={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -318,7 +331,7 @@ END:VCARD`;
|
||||
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
||||
</div>
|
||||
{qr.type === 'DYNAMIC' && (
|
||||
<div className="border-t border-slate-100 pt-2">
|
||||
<div className="border-t border-slate-100 pt-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
|
||||
</p>
|
||||
@@ -337,4 +350,4 @@ END:VCARD`;
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
165
src/lib/bulk-content.ts
Normal file
165
src/lib/bulk-content.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -139,6 +139,15 @@ export const RateLimits = {
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Bulk create: 10 batches per minute.
|
||||
// One upload is one request here, so this limits uploads rather than rows.
|
||||
// Reusing QR_CREATE would have capped a batch at 20 codes.
|
||||
QR_BULK_CREATE: {
|
||||
name: 'qr-bulk-create',
|
||||
maxRequests: 10,
|
||||
windowSeconds: 60,
|
||||
},
|
||||
|
||||
// Modify QR: 30 per minute
|
||||
QR_MODIFY: {
|
||||
name: 'qr-modify',
|
||||
|
||||
Reference in New Issue
Block a user