/** * 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, }, } : {}), }; }