From ab63d4b9161ec37c91b6681144da4f1494fa0c5e Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Mon, 27 Jul 2026 20:47:36 +0200 Subject: [PATCH] fix V2 --- src/app/(main)/(app)/bulk-creation/page.tsx | 421 +++++++++----- src/app/(main)/(app)/create/page.tsx | 591 ++++++++++---------- src/app/(main)/(app)/upgrade/page.tsx | 104 +++- src/app/(main)/api/qrs/bulk/route.ts | 204 +++++++ src/app/(main)/api/qrs/route.ts | 14 +- src/components/app/UpgradeModal.tsx | 101 +++- src/components/dashboard/QRCodeCard.tsx | 137 +++-- src/lib/bulk-content.ts | 165 ++++++ src/lib/rateLimit.ts | 9 + 9 files changed, 1199 insertions(+), 547 deletions(-) create mode 100644 src/app/(main)/api/qrs/bulk/route.ts create mode 100644 src/lib/bulk-content.ts diff --git a/src/app/(main)/(app)/bulk-creation/page.tsx b/src/app/(main)/(app)/bulk-creation/page.tsx index b71687a..cc10359 100644 --- a/src/app/(main)/(app)/bulk-creation/page.tsx +++ b/src/app/(main)/(app)/bulk-creation/page.tsx @@ -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([]); const [userPlan, setUserPlan] = useState('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 ( +
+
+
+
+
+
+
+
+ ); + } + // Show upgrade prompt if not Business or Enterprise plan if (userPlan !== 'BUSINESS' && userPlan !== 'ENTERPRISE') { return ( @@ -473,26 +544,26 @@ export default function BulkCreationPage() {

{t('bulk.title')}

{t('bulk.subtitle')}

- {/* Apply a saved design to the whole batch. */} - {presets.length > 0 && ( -
- - setPresetId(e.target.value)} + options={[ + { value: '', label: 'Plain black and white' }, + ...presets.map((p) => ({ value: p.id, label: p.name })), + ]} + /> +

+ Applied to every code in this upload, so the whole batch matches. +

+
+ )} + {/* Static / Dynamic Toggle */}
QR Code Type: @@ -551,11 +622,25 @@ export default function BulkCreationPage() { + {/* 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. */}
-

Static QR Codes Only

+

+ {isDynamic ? 'Dynamic QR Codes' : 'Static QR Codes'} +

- Bulk creation generates static QR codes 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 trackable short link you can re-point later. + The content column must hold a web address, and each code uses one dynamic slot. + + ) : ( + <> + Bulk creation generates static QR codes that cannot be edited after creation. + These QR codes do not include tracking or analytics. Perfect for print materials and offline use. + + )}

@@ -827,22 +912,37 @@ export default function BulkCreationPage() { - {data.slice(0, 5).map((row: any, index) => ( - - - - - - {row[mapping.title] || 'Untitled'} - - - {(row[mapping.content] || '').substring(0, 50)}... - - - ))} + {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 ( + + +
+ + + {cellToString(row[mapping.title]) || 'Untitled'} + + {detected.contentType} + + + + {raw.length > 50 ? `${raw.substring(0, 50)}...` : raw} + + + ); + })}
@@ -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`}
@@ -887,7 +987,9 @@ export default function BulkCreationPage() {

{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.`}

{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 @@ -968,8 +1074,21 @@ export default function BulkCreationPage() { Download All as ZIP - {!isDynamic && ( - + + ) : ( + - ); - })} -
-
- - {/* Eye styles. Only the combinations that survived decoding are - offered - see the note in lib/qr-shapes.ts. */} -
- { - if (!canUseShapes) { - setUpgradeReason('shapes'); - setUpgradeOpen(true); - return; - } - setEyeBallShape(e.target.value as EyeBallShape); - }} - options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({ - value, - label, - }))} - /> -
- - {/* Gradient. Business only - the renderer takes it as a prop, so - this is purely a gating and input concern. */} -
-
- - {!canUseFullDesign && Business} -
-
- {(['none', 'linear', 'radial'] as const).map((mode) => ( - - ))} -
- {gradientMode !== 'none' && ( -
- - setGradientTo(e.target.value)} - className="h-10 w-12 rounded border border-gray-300" - /> - setGradientTo(e.target.value)} - className="flex-1" - /> -
- )} -
- - {/* Scannability. Says what was changed and why, rather than - silently raising the error correction behind the user. */} - {(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && ( -
-

- {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.`} -

-

- Print it at 2 x 2 cm or larger, and scan it once with your own - phone before you send it to the printer. -

-
- )} - - {/* Saved presets. Business only. Repeatability is the actual - product here - the star shape is not what an agency buys. */} - {canUseFullDesign && ( -
- - {presets.length > 0 && ( -
- {presets.map((preset) => ( - - - - - ))} -
- )} -
- setPresetName(e.target.value)} - placeholder="Client A" - className="flex-1" - /> - -
-

- Saving under an existing name overwrites it. -

-
- )} - + {/* 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. */} +
+
+ + {!canUseShapes && Pro} +
+
+ {([...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 ( + + ); + })} +
+
+ + {/* Eye styles. Only the combinations that survived decoding are + offered - see the note in lib/qr-shapes.ts. */} +
+ { + if (!canUseShapes) { + setUpgradeReason('shapes'); + setUpgradeOpen(true); + return; + } + setEyeBallShape(e.target.value as EyeBallShape); + }} + options={Object.entries(EYE_BALL_LABELS).map(([value, label]) => ({ + value, + label, + }))} + /> +
+ + {/* Gradient. Business only - the renderer takes it as a prop, so + this is purely a gating and input concern. */} +
+
+ + {!canUseFullDesign && Business} +
+
+ {(['none', 'linear', 'radial'] as const).map((mode) => ( + + ))} +
+ {gradientMode !== 'none' && ( +
+ + setGradientTo(e.target.value)} + className="h-10 w-12 rounded border border-gray-300" + /> + setGradientTo(e.target.value)} + className="flex-1" + /> +
+ )} +
+ + {/* Scannability. Says what was changed and why, rather than + silently raising the error correction behind the user. */} + {(LOW_COVERAGE_SHAPES.includes(moduleShape) || logoUrl) && ( +
+

+ {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.`} +

+

+ Print it at 2 x 2 cm or larger, and scan it once with your own + phone before you send it to the printer. +

+
+ )} + + {/* Saved presets. Business only. Repeatability is the actual + product here - the star shape is not what an agency buys. */} + {canUseFullDesign && ( +
+ + {presets.length > 0 && ( +
+ {presets.map((preset) => ( + + + + + ))} +
+ )} +
+ setPresetName(e.target.value)} + placeholder="Client A" + className="flex-1" + /> + +
+

+ Saving under an existing name overwrites it. +

+
+ )} + {/* Frame Options */}
-
+
{frameOptions.map((frame: { id: string; label: string }) => ( + ) : isDowngrade ? ( + ) : plan.key === 'FREE' ? (
)} - + {copy.ctaType === 'contact' ? ( + + {copy.cta} + + ) : ( + + )} {reason === 'limit' && onPauseCode && activeCodes.length > 0 && !showCodeList && (
-
- {/* Download wrapper - tightly wraps content */} -
+
+ {/* Download wrapper - tightly wraps content */} +
{/* Frame Label */} {qr.style?.frameType && qr.style.frameType !== 'none' && (
) : ( - )}
@@ -318,7 +331,7 @@ END:VCARD`; {formatDate(qr.createdAt)}
{qr.type === 'DYNAMIC' && ( -
+

📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}

@@ -337,4 +350,4 @@ END:VCARD`; ); -}; +}; diff --git a/src/lib/bulk-content.ts b/src/lib/bulk-content.ts new file mode 100644 index 0000000..4ddf2ff --- /dev/null +++ b/src/lib/bulk-content.ts @@ -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; + /** 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 | 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, + }, + } + : {}), + }; +} diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts index 91fc582..9e67ccc 100644 --- a/src/lib/rateLimit.ts +++ b/src/lib/rateLimit.ts @@ -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',