const { useEffect, useMemo, useRef, useState } = React; const { createRoot } = ReactDOM; (() => { const runtime = window.qtRuntime || {}; const parseJsonScript = typeof runtime.parseJsonScript === 'function' ? runtime.parseJsonScript : (id, fallback) => { const node = document.getElementById(id); if (!node) return fallback; try { return JSON.parse(node.textContent || '{}'); } catch (error) { return fallback; } }; const readLanguageCookie = typeof runtime.readLanguageCookie === 'function' ? runtime.readLanguageCookie : () => { const source = document.cookie || ''; const item = source .split('; ') .find((entry) => entry.indexOf('qt_lang=') === 0); return item ? decodeURIComponent(item.split('=')[1]) : null; }; const writeLanguageCookie = typeof runtime.writeLanguageCookie === 'function' ? runtime.writeLanguageCookie : (value) => { document.cookie = `qt_lang=${encodeURIComponent(value)};path=/;max-age=31536000;samesite=lax`; }; const copyText = typeof runtime.copyText === 'function' ? runtime.copyText : async () => false; const stableStringify = typeof runtime.stableStringify === 'function' ? runtime.stableStringify : (value) => JSON.stringify(value); const encodeUrlState = typeof runtime.encodeUrlState === 'function' ? runtime.encodeUrlState : (state) => btoa(unescape(encodeURIComponent(JSON.stringify({ v: 1, s: state })))); const decodeUrlState = typeof runtime.decodeUrlState === 'function' ? runtime.decodeUrlState : (encoded) => JSON.parse(decodeURIComponent(escape(atob(encoded)))).s; const setUrlStateParam = typeof runtime.setUrlStateParam === 'function' ? runtime.setUrlStateParam : () => false; const getUrlStateParam = typeof runtime.getUrlStateParam === 'function' ? runtime.getUrlStateParam : () => null; const loadScriptOnce = typeof runtime.loadScriptOnce === 'function' ? runtime.loadScriptOnce : null; const createHistoryStore = typeof runtime.createHistoryStore === 'function' ? runtime.createHistoryStore : () => ({ load: () => [], save: () => true, push: () => [], clear: () => [] }); const postServerState = typeof runtime.postServerState === 'function' ? runtime.postServerState : null; const fetchServerState = typeof runtime.fetchServerState === 'function' ? runtime.fetchServerState : null; const translations = parseJsonScript('home-translations', {}); const csrfToken = (() => { const meta = document.querySelector('meta[name="csrf-token"]'); if (!meta) return ''; return String(meta.getAttribute('content') || ''); })(); const recaptchaSiteKey = (() => { const mount = document.getElementById('app'); if (mount && mount.dataset && mount.dataset.recaptchaSitekey) { return String(mount.dataset.recaptchaSitekey); } return ''; })(); const UI = { en: { title: 'QR Generator', subtitle: 'Template-driven QR payloads, bulk CSV generation, print export, scanability checks, and dynamic links with stats.', modeStatic: 'Static', modeDynamic: 'Dynamic', type: 'Type', payload: 'Payload preview', copyPayload: 'Copy payload', share: 'Copy share link', copy: 'Copy', export: 'Download / Export', reset: 'Reset', swap: 'Swap colors', settings: 'Settings', history: 'History', preview: 'QR preview', navMenu: 'Tools menu', statusReady: 'Ready.', statusCopied: 'Copied to clipboard.', statusReset: 'State reset to defaults.', statusShared: 'Share link copied.', statusExported: 'Export generated.', statusSavedDynamic: 'Dynamic link saved.', statusLoadedStats: 'Statistics loaded.', statusBatchDone: 'Batch generation completed.', statusBatchPartial: 'Batch cancelled. Partial ZIP generated.', statusBatchCancelled: 'Batch cancelled.', statusError: 'Action failed.', errors: { urlRequired: 'Field: URL. Reason: URL is required. Fix: enter an http/https URL.', baseUrlRequired: 'Field: Base URL. Reason: Base URL is required. Fix: enter an http/https URL.', invalidScheme: 'Field: URL. Reason: invalid scheme. Fix: use http:// or https://.', wifiSsid: 'Field: SSID. Reason: SSID is required. Fix: enter your network name.', wifiPassword: 'Field: Password. Reason: password is required for WPA/WEP. Fix: enter a password or use nopass.', vcardFn: 'Field: FN. Reason: full name is required. Fix: enter FN.', smsNumber: 'Field: Number. Reason: recipient number required. Fix: enter phone number.', emailTo: 'Field: To. Reason: recipient email required. Fix: enter email address.', calendarSummary: 'Field: Summary. Reason: summary required. Fix: enter event title.', calendarStartEnd: 'Field: Start/End. Reason: both dates required. Fix: set start and end time.', geoLatLon: 'Field: Lat/Lon. Reason: invalid coordinates. Fix: lat -90..90 and lon -180..180.', deepLink: 'Field: Scheme URL. Reason: deep link required. Fix: enter scheme URL.', recaptcha: 'Complete the reCAPTCHA challenge to continue.', }, templates: { url: 'URL', url_utm: 'URL + UTM', wifi: 'Wi-Fi', vcard: 'vCard', sms: 'SMS', email: 'Email', calendar: 'Calendar (ICS)', geo: 'Geo', deep_link: 'App deep link', }, dynamic: { title: 'Dynamic link', id: 'Link ID', shortUrl: 'Short URL', ttl: 'TTL days (optional)', anonymize: 'anonymizeIp', save: 'Save dynamic link', disable: 'Disable', loadStats: 'Load stats', exportCsv: 'Export CSV', }, scanability: { title: 'Scanability', contrast: 'Contrast ratio', threshold: 'Contrast threshold', logoCoverage: 'Logo coverage', strictMode: 'Strict mode', recommendedEcc: 'Recommended ECC', autoFix: 'Auto-fix contrast', }, batch: { title: 'CSV -> ZIP batch', upload: 'Upload CSV', paste: 'Paste CSV', parse: 'Parse CSV', generateValid: 'Generate only valid', stopFirst: 'Stop on first error', generate: 'Generate ZIP', cancel: 'Cancel', report: 'Validation report', }, exportSettings: { title: 'Export settings', format: 'Format', sizePx: 'PNG size px', dpi: 'PNG DPI metadata', bg: 'Background', pageSize: 'PDF page size', marginMm: 'PDF margin (mm)', bleedMm: 'PDF bleed (mm)', cropMarks: 'Crop marks', quietZone: 'Quiet zone modules', logoSize: 'Logo size %', logoPadding: 'Logo padding modules', icsMode: 'ICS embed mode', }, stats: { daily: 'Daily views', referrers: 'Top referrers', platforms: 'Top platforms', }, historyEmpty: 'No history entries yet.', csv: { rows: 'Rows', valid: 'Valid', errors: 'Errors', }, }, lt: { title: 'QR generatorius', subtitle: 'QR šablonų payload, masinis CSV generavimas, spaudos eksportas, skaitomumo tikrinimas ir dinaminės nuorodos su statistika.', modeStatic: 'Statinis', modeDynamic: 'Dinaminis', type: 'Tipas', payload: 'Payload peržiūra', copyPayload: 'Kopijuoti payload', share: 'Kopijuoti share nuorodą', copy: 'Kopijuoti', export: 'Atsisiųsti / Eksportuoti', reset: 'Atstatyti', swap: 'Sukeisti spalvas', settings: 'Nustatymai', history: 'Istorija', preview: 'QR peržiūra', navMenu: 'Įrankių meniu', statusReady: 'Paruošta.', statusCopied: 'Nukopijuota į iškarpinę.', statusReset: 'Būsena atstatyta.', statusShared: 'Dalinimosi nuoroda nukopijuota.', statusExported: 'Eksportas sugeneruotas.', statusSavedDynamic: 'Dinaminė nuoroda išsaugota.', statusLoadedStats: 'Statistika įkelta.', statusBatchDone: 'Masinis generavimas baigtas.', statusBatchPartial: 'Generavimas atšauktas. Dalinis ZIP sugeneruotas.', statusBatchCancelled: 'Generavimas atšauktas.', statusError: 'Veiksmas nepavyko.', errors: { urlRequired: 'Laukas: URL. Priežastis: URL privalomas. Taisymas: įveskite http/https URL.', baseUrlRequired: 'Laukas: Base URL. Priežastis: base URL privalomas. Taisymas: įveskite http/https URL.', invalidScheme: 'Laukas: URL. Priežastis: netinkama schema. Taisymas: naudokite http:// arba https://.', wifiSsid: 'Laukas: SSID. Priežastis: SSID privalomas. Taisymas: įveskite tinklo pavadinimą.', wifiPassword: 'Laukas: Password. Priežastis: WPA/WEP reikalauja slaptažodžio. Taisymas: įveskite slaptažodį arba pasirinkite nopass.', vcardFn: 'Laukas: FN. Priežastis: vardas privalomas. Taisymas: įveskite FN.', smsNumber: 'Laukas: Number. Priežastis: numeris privalomas. Taisymas: įveskite telefono numerį.', emailTo: 'Laukas: To. Priežastis: el. paštas privalomas. Taisymas: įveskite el. paštą.', calendarSummary: 'Laukas: Summary. Priežastis: pavadinimas privalomas. Taisymas: įveskite įvykio pavadinimą.', calendarStartEnd: 'Laukas: Start/End. Priežastis: abi datos privalomos. Taisymas: nustatykite pradžią ir pabaigą.', geoLatLon: 'Laukas: Lat/Lon. Priežastis: neteisingos koordinatės. Taisymas: lat -90..90, lon -180..180.', deepLink: 'Laukas: Scheme URL. Priežastis: deep link privalomas. Taisymas: įveskite scheme URL.', recaptcha: 'Prieš tęsdami užbaikite reCAPTCHA patikrą.', }, templates: { url: 'URL', url_utm: 'URL + UTM', wifi: 'Wi-Fi', vcard: 'vCard', sms: 'SMS', email: 'Email', calendar: 'Kalendorius (ICS)', geo: 'Geo', deep_link: 'Programėlės deep link', }, dynamic: { title: 'Dinaminė nuoroda', id: 'Nuorodos ID', shortUrl: 'Trumpa nuoroda', ttl: 'TTL dienos (nebūtina)', anonymize: 'anonymizeIp', save: 'Išsaugoti dinaminę nuorodą', disable: 'Išjungti', loadStats: 'Įkelti statistiką', exportCsv: 'Eksportuoti CSV', }, scanability: { title: 'Skaitomumas', contrast: 'Kontrasto santykis', threshold: 'Kontrasto slenkstis', logoCoverage: 'Logotipo uždengimas', strictMode: 'Griežtas režimas', recommendedEcc: 'Rekomenduojamas ECC', autoFix: 'Automatinis kontrasto pataisymas', }, batch: { title: 'CSV -> ZIP masinis generavimas', upload: 'Įkelti CSV', paste: 'Įklijuoti CSV', parse: 'Nuskaityti CSV', generateValid: 'Generuoti tik validžias eilutes', stopFirst: 'Stabdyti ties pirma klaida', generate: 'Generuoti ZIP', cancel: 'Atšaukti', report: 'Validacijos ataskaita', }, exportSettings: { title: 'Eksporto nustatymai', format: 'Formatas', sizePx: 'PNG dydis px', dpi: 'PNG DPI metaduomenys', bg: 'Fonas', pageSize: 'PDF lapo dydis', marginMm: 'PDF paraštė (mm)', bleedMm: 'PDF bleed (mm)', cropMarks: 'Crop marks', quietZone: 'Quiet zone moduliai', logoSize: 'Logotipo dydis %', logoPadding: 'Logotipo padding moduliai', icsMode: 'ICS įdėjimo režimas', }, stats: { daily: 'Peržiūros pagal dienas', referrers: 'Top referreriai', platforms: 'Top platformos', }, historyEmpty: 'Istorija tuščia.', csv: { rows: 'Eilučių', valid: 'Validžios', errors: 'Klaidos', }, }, }; const TYPE_FIELDS = { url: ['url'], url_utm: ['baseUrl', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'], wifi: ['ssid', 'password', 'encryption', 'hidden'], vcard: ['fn', 'tel', 'email', 'org', 'title', 'url', 'adr', 'note'], sms: ['number', 'message'], email: ['to', 'subject', 'body'], calendar: ['summary', 'start', 'end', 'location', 'description', 'timezone'], geo: ['lat', 'lon', 'query'], deep_link: ['schemeUrl', 'fallbackUrl', 'platform'], }; const DEFAULT_FIELDS = { url: 'https://example.com', baseUrl: 'https://example.com', utm_source: '', utm_medium: '', utm_campaign: '', utm_term: '', utm_content: '', ssid: '', password: '', encryption: 'WPA', hidden: false, fn: '', tel: '', email: '', org: '', title: '', adr: '', note: '', number: '', message: '', to: '', subject: '', body: '', summary: '', start: '', end: '', location: '', description: '', timezone: 'UTC', lat: '', lon: '', query: '', schemeUrl: '', fallbackUrl: '', platform: 'any', }; const DEFAULT_STATE = { mode: 'static', type: 'url', fields: DEFAULT_FIELDS, settings: { foreground: '#111827', background: '#ffffff', shape: 'square', ecc: 'M', quietZoneModules: 4, strictMode: true, contrastThreshold: 4.5, logoSizePercent: 0, logoPaddingModules: 1, icsMode: 'raw', exportFormat: 'png', sizePx: 1024, dpi: 300, pngBackground: 'solid', pdfPageSize: 'A4', pdfMarginMm: 10, pdfBleedMm: 3, pdfCropMarks: false, }, dynamic: { id: '', shortUrl: '', status: 'active', ttlDays: '', anonymizeIp: true, }, logoDataUrl: '', }; const ECC_ORDER = ['L', 'M', 'Q', 'H']; const readHexColor = (value, fallback) => { const raw = String(value || '').trim().toLowerCase(); if (/^#[0-9a-f]{6}$/.test(raw)) return raw; if (/^#[0-9a-f]{3}$/.test(raw)) { return `#${raw .slice(1) .split('') .map((char) => char + char) .join('')}`; } return fallback; }; const parseIsoDateTime = (value) => { if (!value) return null; const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; }; const formatIcsDate = (date) => { const y = date.getUTCFullYear(); const m = String(date.getUTCMonth() + 1).padStart(2, '0'); const d = String(date.getUTCDate()).padStart(2, '0'); const hh = String(date.getUTCHours()).padStart(2, '0'); const mm = String(date.getUTCMinutes()).padStart(2, '0'); const ss = String(date.getUTCSeconds()).padStart(2, '0'); return `${y}${m}${d}T${hh}${mm}${ss}Z`; }; const urlEncodeMailto = (value) => encodeURIComponent(String(value || '')); const escapeWifi = (value) => String(value || '').replace(/([\\;,:\"])/g, '\\$1'); const vcardEscape = (value) => String(value || '') .replace(/\\/g, '\\\\') .replace(/\n/g, '\\n') .replace(/;/g, '\\;') .replace(/,/g, '\\,'); const safeHash = (input) => { const source = String(input || ''); let h = 2166136261; for (let i = 0; i < source.length; i += 1) { h ^= source.charCodeAt(i); h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); } return (h >>> 0).toString(16).padStart(8, '0').slice(0, 8); }; const parseCsvText = (input) => { const text = String(input || ''); const rows = []; let row = []; let value = ''; let inQuotes = false; for (let i = 0; i < text.length; i += 1) { const char = text[i]; const next = text[i + 1]; if (inQuotes) { if (char === '"' && next === '"') { value += '"'; i += 1; } else if (char === '"') { inQuotes = false; } else { value += char; } continue; } if (char === '"') { inQuotes = true; } else if (char === ',') { row.push(value); value = ''; } else if (char === '\n') { row.push(value); rows.push(row); row = []; value = ''; } else if (char === '\r') { // ignore CR } else { value += char; } } if (value !== '' || row.length) { row.push(value); rows.push(row); } if (!rows.length) { return { header: [], rows: [] }; } const header = rows[0].map((item) => String(item || '').trim()); const body = rows.slice(1).map((cells, index) => { const obj = {}; header.forEach((key, idx) => { obj[key] = cells[idx] == null ? '' : String(cells[idx]); }); obj.__row = index + 2; return obj; }); return { header, rows: body }; }; const relativeLuminance = (hexColor) => { const hex = readHexColor(hexColor, '#000000').slice(1); const rgb = [0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255); const normalize = (value) => (value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4)); const [r, g, b] = rgb.map(normalize); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const contrastRatio = (fg, bg) => { const l1 = relativeLuminance(fg); const l2 = relativeLuminance(bg); const light = Math.max(l1, l2); const dark = Math.min(l1, l2); return (light + 0.05) / (dark + 0.05); }; const recommendEcc = ({ logoSizePercent, shape, printSize }) => { let score = 0; if (logoSizePercent >= 20) score += 2; else if (logoSizePercent >= 10) score += 1; if (shape !== 'square') score += 1; if (printSize <= 512) score += 1; if (score >= 3) return 'H'; if (score >= 2) return 'Q'; if (score >= 1) return 'M'; return 'L'; }; const validateTemplate = (type, fields, t) => { const errors = []; const hasHttpScheme = (value) => /^https?:\/\//i.test(String(value || '')); if (type === 'url') { if (!fields.url) { errors.push(t.errors.urlRequired); } else if (!hasHttpScheme(fields.url) && !/^mailto:/i.test(fields.url)) { errors.push(t.errors.invalidScheme); } } if (type === 'url_utm') { if (!fields.baseUrl) { errors.push(t.errors.baseUrlRequired); } else if (!hasHttpScheme(fields.baseUrl)) { errors.push(t.errors.invalidScheme); } } if (type === 'wifi') { if (!fields.ssid) { errors.push(t.errors.wifiSsid); } if ((fields.encryption === 'WPA' || fields.encryption === 'WEP') && !fields.password) { errors.push(t.errors.wifiPassword); } } if (type === 'vcard' && !fields.fn) { errors.push(t.errors.vcardFn); } if (type === 'sms' && !fields.number) { errors.push(t.errors.smsNumber); } if (type === 'email' && !fields.to) { errors.push(t.errors.emailTo); } if (type === 'calendar') { if (!fields.summary) { errors.push(t.errors.calendarSummary); } if (!fields.start || !fields.end) { errors.push(t.errors.calendarStartEnd); } } if (type === 'geo') { const lat = Number(fields.lat); const lon = Number(fields.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) { errors.push(t.errors.geoLatLon); } } if (type === 'deep_link' && !fields.schemeUrl) { errors.push(t.errors.deepLink); } return errors; }; const buildPayload = (type, fields, settings) => { if (type === 'url') { return String(fields.url || '').trim(); } if (type === 'url_utm') { const base = String(fields.baseUrl || '').trim(); if (!base) return ''; let url; try { url = new URL(base); } catch (error) { return base; } ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'].forEach((key) => { const value = String(fields[key] || '').trim(); if (value) { url.searchParams.set(key, value); } else { url.searchParams.delete(key); } }); return url.toString(); } if (type === 'wifi') { const encryption = String(fields.encryption || 'WPA'); const hidden = !!fields.hidden; return `WIFI:T:${escapeWifi(encryption)};S:${escapeWifi(fields.ssid || '')};P:${escapeWifi(fields.password || '')};H:${hidden ? 'true' : 'false'};;`; } if (type === 'vcard') { const lines = [ 'BEGIN:VCARD', 'VERSION:3.0', `FN:${vcardEscape(fields.fn || '')}`, ]; if (fields.tel) lines.push(`TEL:${vcardEscape(fields.tel)}`); if (fields.email) lines.push(`EMAIL:${vcardEscape(fields.email)}`); if (fields.org) lines.push(`ORG:${vcardEscape(fields.org)}`); if (fields.title) lines.push(`TITLE:${vcardEscape(fields.title)}`); if (fields.url) lines.push(`URL:${vcardEscape(fields.url)}`); if (fields.adr) lines.push(`ADR:${vcardEscape(fields.adr)}`); if (fields.note) lines.push(`NOTE:${vcardEscape(fields.note)}`); lines.push('END:VCARD'); return lines.join('\n'); } if (type === 'sms') { const number = String(fields.number || '').trim(); const body = String(fields.message || ''); return `SMSTO:${number}:${body}`; } if (type === 'email') { const to = String(fields.to || '').trim(); const params = []; if (fields.subject) params.push(`subject=${urlEncodeMailto(fields.subject)}`); if (fields.body) params.push(`body=${urlEncodeMailto(fields.body)}`); return `mailto:${to}${params.length ? `?${params.join('&')}` : ''}`; } if (type === 'calendar') { const start = parseIsoDateTime(fields.start); const end = parseIsoDateTime(fields.end); if (!start || !end) return ''; const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//QuickTools//QR Calendar//EN', 'BEGIN:VEVENT', `UID:${safeHash(`${fields.summary || ''}${fields.start || ''}${fields.end || ''}`)}@getqt.io`, `DTSTAMP:${formatIcsDate(new Date())}`, `DTSTART:${formatIcsDate(start)}`, `DTEND:${formatIcsDate(end)}`, `SUMMARY:${vcardEscape(fields.summary || '')}`, ]; if (fields.location) lines.push(`LOCATION:${vcardEscape(fields.location)}`); if (fields.description) lines.push(`DESCRIPTION:${vcardEscape(fields.description)}`); if (fields.timezone) lines.push(`X-WR-TIMEZONE:${vcardEscape(fields.timezone)}`); lines.push('END:VEVENT', 'END:VCALENDAR'); const raw = lines.join('\n'); if (settings.icsMode === 'data_uri') { return `data:text/calendar;charset=utf-8,${encodeURIComponent(raw)}`; } return raw; } if (type === 'geo') { const lat = String(fields.lat || '').trim(); const lon = String(fields.lon || '').trim(); const query = String(fields.query || '').trim(); if (query) { return `geo:${lat},${lon}?q=${encodeURIComponent(query)}`; } return `geo:${lat},${lon}`; } if (type === 'deep_link') { return String(fields.schemeUrl || '').trim(); } return ''; }; const REQUIRED_MAPPING_FIELDS = { url: ['url'], url_utm: ['baseUrl'], wifi: ['ssid', 'encryption', 'password', 'hidden'], vcard: ['fn', 'tel', 'email', 'org', 'title', 'url', 'adr', 'note'], sms: ['number', 'message'], email: ['to', 'subject', 'body'], calendar: ['summary', 'start', 'end', 'location', 'description', 'timezone'], geo: ['lat', 'lon', 'query'], deep_link: ['schemeUrl', 'fallbackUrl', 'platform'], }; const LANGUAGE_OPTIONS = [ { id: 'en', label: 'English', flag: '/assets/img/flag-en.svg' }, { id: 'lt', label: 'Lietuviu', flag: '/assets/img/flag-lt.svg' }, ]; const SHAPES = ['square', 'rounded', 'dots', 'classy', 'classy-rounded']; const PREVIEW_QR_SIZE = 320; const downloadBlob = (blob, filename) => { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const ensureJsPdf = async () => { if (window.jspdf && window.jspdf.jsPDF) { return window.jspdf.jsPDF; } if (!loadScriptOnce) { throw new Error('jsPDF loader unavailable'); } loadScriptOnce('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', { async: true, crossorigin: 'anonymous', }); await new Promise((resolve, reject) => { const started = Date.now(); const poll = () => { if (window.jspdf && window.jspdf.jsPDF) { resolve(); return; } if (Date.now() - started > 12000) { reject(new Error('jsPDF load timeout')); return; } setTimeout(poll, 120); }; poll(); }); return window.jspdf.jsPDF; }; const ensureJsZip = async () => { if (window.JSZip) return window.JSZip; if (!loadScriptOnce) { throw new Error('JSZip loader unavailable'); } loadScriptOnce('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', { async: true, crossorigin: 'anonymous', }); await new Promise((resolve, reject) => { const started = Date.now(); const poll = () => { if (window.JSZip) { resolve(); return; } if (Date.now() - started > 12000) { reject(new Error('JSZip load timeout')); return; } setTimeout(poll, 120); }; poll(); }); return window.JSZip; }; const buildQrConfig = (payload, state, options = {}) => { const dotsMap = { square: 'square', rounded: 'rounded', dots: 'dots', classy: 'classy', 'classy-rounded': 'classy-rounded', }; const settings = state.settings; const overrideSize = Number(options.sizePx); const sizePx = Number.isFinite(overrideSize) && overrideSize > 0 ? Math.round(overrideSize) : Number(settings.sizePx) || 1024; const config = { width: sizePx, height: sizePx, type: 'canvas', data: payload || ' ', margin: Number(settings.quietZoneModules) || 4, qrOptions: { errorCorrectionLevel: settings.ecc || 'M', }, dotsOptions: { type: dotsMap[settings.shape] || 'square', color: readHexColor(settings.foreground, '#111827'), }, backgroundOptions: { color: settings.pngBackground === 'transparent' ? 'transparent' : readHexColor(settings.background, '#ffffff'), }, }; if (state.logoDataUrl) { const logoScale = Math.min(Math.max(Number(settings.logoSizePercent || 0) / 100, 0.05), 0.35); config.image = state.logoDataUrl; config.imageOptions = { imageSize: logoScale, margin: Math.max(0, Number(settings.logoPaddingModules || 0)) * 2, hideBackgroundDots: true, crossOrigin: 'anonymous', }; } return config; }; const createQrBlob = async (payload, appState, targetFormat) => { if (!window.QRCodeStyling) { throw new Error('QR renderer is unavailable'); } const config = buildQrConfig(payload, appState); config.type = targetFormat === 'svg' ? 'svg' : 'canvas'; const qr = new window.QRCodeStyling(config); if (targetFormat === 'svg') { const svgBlob = await qr.getRawData('svg'); if (!(svgBlob instanceof Blob)) { throw new Error('SVG export failed'); } return { blob: svgBlob, ext: 'svg', mime: 'image/svg+xml' }; } const pngBlob = await qr.getRawData('png'); if (!(pngBlob instanceof Blob)) { throw new Error('PNG export failed'); } if (targetFormat === 'png') { return { blob: pngBlob, ext: 'png', mime: 'image/png' }; } const JsPdf = await ensureJsPdf(); const settings = appState.settings; const pageSize = settings.pdfPageSize || 'A4'; const margin = Number(settings.pdfMarginMm) || 10; const bleed = Number(settings.pdfBleedMm) || 0; const pageConfig = (() => { if (pageSize === 'Letter') return [215.9, 279.4]; if (pageSize === 'Custom') return [210, 210]; return [210, 297]; })(); const pdf = new JsPdf({ orientation: pageConfig[0] >= pageConfig[1] ? 'landscape' : 'portrait', unit: 'mm', format: pageConfig, compress: true, }); const dataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.readAsDataURL(pngBlob); }); const drawW = pageConfig[0] - margin * 2; const drawH = pageConfig[1] - margin * 2; const drawSize = Math.min(drawW, drawH); const x = (pageConfig[0] - drawSize) / 2; const y = (pageConfig[1] - drawSize) / 2; pdf.addImage(dataUrl, 'PNG', x, y, drawSize, drawSize); if (settings.pdfCropMarks) { const crop = 3 + bleed; const lines = [ [x - crop, y, x - 0.5, y], [x, y - crop, x, y - 0.5], [x + drawSize + 0.5, y, x + drawSize + crop, y], [x + drawSize, y - crop, x + drawSize, y - 0.5], [x - crop, y + drawSize, x - 0.5, y + drawSize], [x, y + drawSize + 0.5, x, y + drawSize + crop], [x + drawSize + 0.5, y + drawSize, x + drawSize + crop, y + drawSize], [x + drawSize, y + drawSize + 0.5, x + drawSize, y + drawSize + crop], ]; lines.forEach((line) => { pdf.line(line[0], line[1], line[2], line[3]); }); } const pdfBlob = pdf.output('blob'); return { blob: pdfBlob, ext: 'pdf', mime: 'application/pdf' }; }; const App = () => { const languageInit = String(readLanguageCookie() || document.documentElement.getAttribute('lang') || 'en').toLowerCase(); const [language, setLanguage] = useState(UI[languageInit] ? languageInit : 'en'); const [state, setState] = useState(() => { try { const raw = window.localStorage.getItem('qt_qr_state_v4'); if (!raw) return DEFAULT_STATE; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE; return { ...DEFAULT_STATE, ...parsed, fields: { ...DEFAULT_FIELDS, ...(parsed.fields || {}) }, settings: { ...DEFAULT_STATE.settings, ...(parsed.settings || {}) }, dynamic: { ...DEFAULT_STATE.dynamic, ...(parsed.dynamic || {}) }, }; } catch (error) { return DEFAULT_STATE; } }); const [status, setStatus] = useState({ message: (UI[language] || UI.en).statusReady, tone: 'text-slate-500', }); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [showSettings, setShowSettings] = useState(true); const [showHistory, setShowHistory] = useState(false); const [showBatch, setShowBatch] = useState(false); const [csvText, setCsvText] = useState(''); const [csvData, setCsvData] = useState({ header: [], rows: [] }); const [mapping, setMapping] = useState({}); const [logoUploadName, setLogoUploadName] = useState(''); const [batchUploadName, setBatchUploadName] = useState(''); const [batchMode, setBatchMode] = useState('generate_valid'); const [batchReport, setBatchReport] = useState({ rows: 0, validRows: [], errors: [] }); const [batchProgress, setBatchProgress] = useState({ active: false, processed: 0, total: 0 }); const [dynamicStats, setDynamicStats] = useState(null); const [dynamicStatsLoading, setDynamicStatsLoading] = useState(false); const [recaptchaToken, setRecaptchaToken] = useState(''); const [recaptchaError, setRecaptchaError] = useState(''); const historyStoreRef = useRef(createHistoryStore('qt_qr_history_v4', 20)); const [historyEntries, setHistoryEntries] = useState(() => historyStoreRef.current.load()); const qrContainerRef = useRef(null); const qrRef = useRef(null); const batchCancelRef = useRef(false); const recaptchaContainerRef = useRef(null); const recaptchaWidgetIdRef = useRef(null); const pageT = UI[language] || UI.en; const logoUploadLabel = language === 'lt' ? 'Logotipo paveikslėlis' : 'Logo image'; const logoUploadHint = logoUploadName || (language === 'lt' ? 'Pasirinkite logotipo failą' : 'Choose logo image'); const logoUploadMeta = 'PNG, JPG, SVG, WEBP'; const batchUploadHint = batchUploadName || (language === 'lt' ? 'Pasirinkite CSV failą' : 'Choose CSV file'); const batchUploadMeta = '.csv'; const toolNavLabels = (() => { const t = translations[language] || translations.en || {}; const nav = t.nav || {}; const fallback = { home: language === 'lt' ? 'QR generatorius' : 'QR generator', textEditor: language === 'lt' ? 'Teksto redaktorius' : 'Text editor', dateTime: language === 'lt' ? 'Data ir laikas' : 'Date & time', converter: language === 'lt' ? 'Konverteris' : 'Converter', pdfEdit: language === 'lt' ? 'PDF redagavimas' : 'PDF edit', coffeeTea: language === 'lt' ? 'Kavos / arbatos įrankis' : 'Coffee & tea counter', uuidGenerator: language === 'lt' ? 'UUID generatorius' : 'UUID generator', hashChecksum: language === 'lt' ? 'Hash / kontrolinė suma' : 'Hash / checksum', passwordGenerator: language === 'lt' ? 'Slaptažodžių generatorius' : 'Password generator', csvJsonConverter: language === 'lt' ? 'CSV ↔ JSON keitiklis' : 'CSV ↔ JSON', imageUtils: language === 'lt' ? 'Paveikslėlių įrankiai' : 'Image utilities', roomPlanner: language === 'lt' ? 'Patalpų 2D planavimas' : '2D room planner', }; return { ...fallback, ...nav }; })(); const toolNavItems = [ { key: 'home', href: '/qr-generator', label: toolNavLabels.home, icon: '/assets/img/icons/tool-home.svg' }, { key: 'textEditor', href: '/text-editor', label: toolNavLabels.textEditor, icon: '/assets/img/icons/tool-text-editor.svg' }, { key: 'dateTime', href: '/date-time', label: toolNavLabels.dateTime, icon: '/assets/img/icons/tool-date-time.svg' }, { key: 'converter', href: '/converter', label: toolNavLabels.converter, icon: '/assets/img/icons/tool-converter.svg' }, { key: 'pdfEdit', href: '/pdf-edit', label: toolNavLabels.pdfEdit, icon: '/assets/img/icons/tool-pdf-edit.svg' }, { key: 'coffeeTea', href: '/coffee-tea', label: toolNavLabels.coffeeTea, icon: '/assets/img/icons/tool-coffee-tea.svg' }, { key: 'uuidGenerator', href: '/uuid-generator', label: toolNavLabels.uuidGenerator, icon: '/assets/img/icons/tool-uuid.svg' }, { key: 'hashChecksum', href: '/hash-checksum', label: toolNavLabels.hashChecksum, icon: '/assets/img/icons/tool-hash.svg' }, { key: 'passwordGenerator', href: '/password-generator', label: toolNavLabels.passwordGenerator, icon: '/assets/img/icons/tool-password.svg', }, { key: 'csvJsonConverter', href: '/csv-json-converter', label: toolNavLabels.csvJsonConverter, icon: '/assets/img/icons/tool-csv-json.svg', }, { key: 'imageUtils', href: '/image-utils', label: toolNavLabels.imageUtils, icon: '/assets/img/icons/tool-image.svg' }, { key: 'roomPlanner', href: '/room-planner', label: toolNavLabels.roomPlanner, icon: '/assets/img/icons/tool-room-planner.svg', }, ]; const templateErrors = useMemo(() => validateTemplate(state.type, state.fields, pageT), [state.type, state.fields, pageT]); const payload = useMemo( () => buildPayload(state.type, state.fields, state.settings), [state.type, state.fields, state.settings] ); const contrast = useMemo( () => contrastRatio(state.settings.foreground, state.settings.background), [state.settings.foreground, state.settings.background] ); const logoCoverage = useMemo(() => { const logo = Number(state.settings.logoSizePercent || 0); const padding = Number(state.settings.logoPaddingModules || 0) * 2.5; return Math.min(100, Math.max(0, Math.pow((logo + padding) / 100, 2) * 100)); }, [state.settings.logoPaddingModules, state.settings.logoSizePercent]); const recommendedEcc = useMemo( () => recommendEcc({ logoSizePercent: Number(state.settings.logoSizePercent || 0), shape: state.settings.shape, printSize: Number(state.settings.sizePx || 1024), }), [state.settings.logoSizePercent, state.settings.shape, state.settings.sizePx] ); const currentQrData = useMemo(() => { if (state.mode === 'dynamic') { return state.dynamic.shortUrl || 'https://getqt.io/q/pending'; } return payload || ' '; }, [state.dynamic.shortUrl, state.mode, payload]); const setStatusMessage = (message, tone = 'text-slate-500') => { setStatus({ message, tone }); }; useEffect(() => { writeLanguageCookie(language); document.documentElement.setAttribute('lang', language); try { window.dispatchEvent(new CustomEvent('qt:language:changed', { detail: { language } })); } catch (error) { // ignore } }, [language]); useEffect(() => { try { window.localStorage.setItem('qt_qr_state_v4', JSON.stringify(state)); } catch (error) { // ignore } }, [state]); useEffect(() => { if (typeof window === 'undefined') { return undefined; } const scope = document.getElementById('app') || document; const init = () => { if (typeof window.__qtInitAnimations === 'function') { window.__qtInitAnimations(scope); return; } const targets = scope.querySelectorAll('[data-animate]'); targets.forEach((node) => node.classList.add('is-animated')); }; let rafId = null; if (typeof window.requestAnimationFrame === 'function') { rafId = window.requestAnimationFrame(init); } else { init(); } return () => { if (rafId !== null && typeof window.cancelAnimationFrame === 'function') { window.cancelAnimationFrame(rafId); } }; }, [mobileMenuOpen, showBatch, showHistory, showSettings]); useEffect(() => { const encoded = getUrlStateParam('s'); const stateId = getUrlStateParam('stateId'); if (encoded) { try { const decoded = decodeUrlState(encoded, 1); setState((prev) => ({ ...prev, ...decoded, fields: { ...DEFAULT_FIELDS, ...(decoded.fields || {}) }, settings: { ...DEFAULT_STATE.settings, ...(decoded.settings || {}) }, dynamic: { ...DEFAULT_STATE.dynamic, ...(decoded.dynamic || {}) }, })); return; } catch (error) { // ignore invalid URL state } } if (stateId && fetchServerState) { fetchServerState(stateId, { type: 'qr', consume: false }) .then((payloadObj) => { if (!payloadObj || !payloadObj.state) return; const decoded = payloadObj.state; setState((prev) => ({ ...prev, ...decoded, fields: { ...DEFAULT_FIELDS, ...(decoded.fields || {}) }, settings: { ...DEFAULT_STATE.settings, ...(decoded.settings || {}) }, dynamic: { ...DEFAULT_STATE.dynamic, ...(decoded.dynamic || {}) }, })); }) .catch(() => { // ignore }); } }, []); useEffect(() => { if (!window.QRCodeStyling || !qrContainerRef.current) { return; } const previewConfig = buildQrConfig(currentQrData, state, { sizePx: PREVIEW_QR_SIZE }); if (!qrRef.current) { qrRef.current = new window.QRCodeStyling(previewConfig); qrRef.current.append(qrContainerRef.current); } else { qrRef.current.update(previewConfig); } const applyPreviewSizing = () => { if (!qrContainerRef.current) return; const rendered = qrContainerRef.current.querySelector('canvas,svg'); if (!rendered) return; rendered.style.width = '100%'; rendered.style.height = '100%'; rendered.style.maxWidth = '100%'; rendered.style.maxHeight = '100%'; rendered.style.display = 'block'; }; if (typeof window.requestAnimationFrame === 'function') { window.requestAnimationFrame(applyPreviewSizing); } else { applyPreviewSizing(); } }, [currentQrData, state]); useEffect(() => { if (!recaptchaSiteKey || !recaptchaContainerRef.current || typeof window === 'undefined') { return; } const render = () => { if (!window.grecaptcha || typeof window.grecaptcha.render !== 'function') { return; } if (recaptchaWidgetIdRef.current !== null) { return; } recaptchaWidgetIdRef.current = window.grecaptcha.render(recaptchaContainerRef.current, { sitekey: recaptchaSiteKey, callback: (token) => { setRecaptchaToken(String(token || '')); setRecaptchaError(''); }, 'expired-callback': () => { setRecaptchaToken(''); setRecaptchaError(pageT.errors.recaptcha); }, 'error-callback': () => { setRecaptchaToken(''); setRecaptchaError(pageT.errors.recaptcha); }, }); }; if (window.grecaptcha && typeof window.grecaptcha.render === 'function') { render(); } else { const listener = () => render(); window.addEventListener('qtRecaptchaLoaded', listener, { once: true }); return () => window.removeEventListener('qtRecaptchaLoaded', listener); } return undefined; }, [pageT.errors.recaptcha]); const resetRecaptcha = () => { if ( recaptchaSiteKey && recaptchaWidgetIdRef.current !== null && window.grecaptcha && typeof window.grecaptcha.reset === 'function' ) { try { window.grecaptcha.reset(recaptchaWidgetIdRef.current); } catch (error) { // ignore } } setRecaptchaToken(''); setRecaptchaError(''); }; const persistHistoryEntry = (label) => { const entry = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, timestamp: new Date().toISOString(), label, state, }; const list = historyStoreRef.current.push(entry); setHistoryEntries(list); }; const onCopyPayload = async () => { if (!payload) return; const ok = await copyText(payload); setStatusMessage(ok ? pageT.statusCopied : pageT.statusError, ok ? 'text-emerald-600' : 'text-rose-600'); }; const onSwapColors = () => { setState((prev) => ({ ...prev, settings: { ...prev.settings, foreground: prev.settings.background, background: prev.settings.foreground, }, })); }; const onReset = () => { setState(DEFAULT_STATE); setCsvText(''); setCsvData({ header: [], rows: [] }); setMapping({}); setLogoUploadName(''); setBatchUploadName(''); setBatchReport({ rows: 0, validRows: [], errors: [] }); setDynamicStats(null); resetRecaptcha(); setStatusMessage(pageT.statusReset, 'text-slate-500'); persistHistoryEntry('reset'); }; const onAutoFixContrast = () => { const blackContrast = contrastRatio('#000000', state.settings.background); const whiteContrast = contrastRatio('#ffffff', state.settings.background); setState((prev) => ({ ...prev, settings: { ...prev.settings, foreground: blackContrast >= whiteContrast ? '#000000' : '#ffffff', }, })); }; const canExportWithStrict = () => { if (!state.settings.strictMode) return true; if (contrast < Number(state.settings.contrastThreshold || 4.5)) return false; if (logoCoverage > 28) return false; const selectedIndex = ECC_ORDER.indexOf(state.settings.ecc || 'M'); const requiredIndex = ECC_ORDER.indexOf(recommendedEcc); if (selectedIndex < requiredIndex) return false; return true; }; const securityPreflight = async () => { if (!recaptchaSiteKey) { return true; } if (!recaptchaToken) { setRecaptchaError(pageT.errors.recaptcha); throw new Error(pageT.errors.recaptcha); } const body = new URLSearchParams(); body.set('qr_text', currentQrData || payload || ' '); body.set('g-recaptcha-response', recaptchaToken); if (csrfToken) body.set('csrf_token', csrfToken); const headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', }; if (csrfToken) headers['X-CSRF-TOKEN'] = csrfToken; const response = await fetch('/qr/create_temp_qr.php', { method: 'POST', headers, body: body.toString(), }); const data = await response.json().catch(() => null); if (!response.ok || !data) { throw new Error((data && data.error) || 'Security verification failed'); } return true; }; const onExport = async () => { try { if (templateErrors.length > 0) { setStatusMessage(templateErrors[0], 'text-rose-600'); return; } if (!canExportWithStrict()) { setStatusMessage('Strict mode blocked export due to scanability checks.', 'text-rose-600'); return; } await securityPreflight(); const exportFormat = state.settings.exportFormat; const result = await createQrBlob(currentQrData, state, exportFormat); const filenameBase = `qr_${safeHash(`${currentQrData}${Date.now()}`)}`; downloadBlob(result.blob, `${filenameBase}.${result.ext}`); setStatusMessage(pageT.statusExported, 'text-emerald-600'); persistHistoryEntry(`export:${result.ext}`); } catch (error) { setStatusMessage((error && error.message) || pageT.statusError, 'text-rose-600'); } finally { if (recaptchaSiteKey) { resetRecaptcha(); } } }; const toSerializableState = () => ({ mode: state.mode, type: state.type, fields: state.fields, settings: state.settings, dynamic: state.dynamic, logoDataUrl: state.logoDataUrl, }); const onCopyShareLink = async () => { try { const serializable = toSerializableState(); const encoded = encodeUrlState(serializable, 1); if (encoded.length <= 1200) { setUrlStateParam('s', encoded, 'replace'); setUrlStateParam('stateId', '', 'replace'); const ok = await copyText(window.location.href); setStatusMessage(ok ? pageT.statusShared : pageT.statusError, ok ? 'text-emerald-600' : 'text-rose-600'); return; } if (!postServerState) { throw new Error('Server fallback is unavailable for large state'); } const server = await postServerState(serializable, { type: 'qr', ttlSeconds: 86400, singleUse: false, csrfToken, }); setUrlStateParam('s', '', 'replace'); setUrlStateParam('stateId', server.stateId, 'replace'); const ok = await copyText(window.location.href); setStatusMessage(ok ? pageT.statusShared : pageT.statusError, ok ? 'text-emerald-600' : 'text-rose-600'); } catch (error) { setStatusMessage((error && error.message) || pageT.statusError, 'text-rose-600'); } }; const onSaveDynamic = async () => { try { if (templateErrors.length > 0) { setStatusMessage(templateErrors[0], 'text-rose-600'); return; } const ttlDaysValue = Number(state.dynamic.ttlDays || '0'); const ttlSeconds = Number.isFinite(ttlDaysValue) && ttlDaysValue > 0 ? Math.floor(ttlDaysValue * 86400) : null; const targetUrl = (() => { if (state.type === 'url') return state.fields.url; if (state.type === 'url_utm') return buildPayload('url_utm', state.fields, state.settings); if (state.type === 'deep_link') return state.fields.fallbackUrl || null; if (/^https?:\/\//i.test(payload)) return payload; return null; })(); const requestPayload = { action: 'upsert', id: state.dynamic.id || undefined, type: state.type, targetUrl, targetPayload: payload || null, deepLinkUrl: state.type === 'deep_link' ? state.fields.schemeUrl || null : null, fallbackUrl: state.type === 'deep_link' ? state.fields.fallbackUrl || null : null, platform: state.type === 'deep_link' ? state.fields.platform || 'any' : 'any', status: state.dynamic.status || 'active', ttlSeconds, anonymizeIp: !!state.dynamic.anonymizeIp, ...(csrfToken ? { csrf_token: csrfToken } : {}), }; const response = await fetch('/qr/dynamic_link.php', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}), }, body: JSON.stringify(requestPayload), }); const result = await response.json().catch(() => null); if (!response.ok || !result || !result.link || !result.shortUrl) { throw new Error((result && result.error) || 'Failed to save dynamic link'); } setState((prev) => ({ ...prev, dynamic: { ...prev.dynamic, id: result.link.id || prev.dynamic.id, shortUrl: result.shortUrl, status: result.link.status || prev.dynamic.status, }, })); setStatusMessage(pageT.statusSavedDynamic, 'text-emerald-600'); persistHistoryEntry(`dynamic:${result.link.id || 'saved'}`); } catch (error) { setStatusMessage((error && error.message) || pageT.statusError, 'text-rose-600'); } }; const onDisableDynamic = async () => { if (!state.dynamic.id) return; try { const response = await fetch('/qr/dynamic_link.php', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}), }, body: JSON.stringify({ action: 'disable', id: state.dynamic.id, ...(csrfToken ? { csrf_token: csrfToken } : {}), }), }); const result = await response.json().catch(() => null); if (!response.ok || !result || !result.ok) { throw new Error((result && result.error) || 'Failed to disable link'); } setState((prev) => ({ ...prev, dynamic: { ...prev.dynamic, status: 'disabled', }, })); setStatusMessage('Dynamic link disabled.', 'text-amber-600'); } catch (error) { setStatusMessage((error && error.message) || pageT.statusError, 'text-rose-600'); } }; const onLoadStats = async () => { if (!state.dynamic.id) return; setDynamicStatsLoading(true); try { const response = await fetch(`/qr/dynamic_stats.php?id=${encodeURIComponent(state.dynamic.id)}`); const result = await response.json().catch(() => null); if (!response.ok || !result || !result.stats) { throw new Error((result && result.error) || 'Failed to load stats'); } setDynamicStats(result.stats); setStatusMessage(pageT.statusLoadedStats, 'text-emerald-600'); } catch (error) { setStatusMessage((error && error.message) || pageT.statusError, 'text-rose-600'); } finally { setDynamicStatsLoading(false); } }; const onExportStatsCsv = () => { if (!state.dynamic.id) return; const url = `/qr/dynamic_stats.php?id=${encodeURIComponent(state.dynamic.id)}&format=csv`; window.open(url, '_blank', 'noopener'); }; const onParseCsv = () => { const parsed = parseCsvText(csvText); setCsvData(parsed); const defaults = {}; const required = REQUIRED_MAPPING_FIELDS[state.type] || []; required.forEach((field) => { const exact = parsed.header.find((col) => col.toLowerCase() === field.toLowerCase()); defaults[field] = exact || ''; }); setMapping(defaults); setBatchReport({ rows: parsed.rows.length, validRows: [], errors: [] }); }; const buildFieldsFromCsvRow = (rowObj) => { const result = { ...state.fields }; const required = REQUIRED_MAPPING_FIELDS[state.type] || []; required.forEach((field) => { const column = mapping[field]; if (!column) return; const raw = rowObj[column]; result[field] = raw == null ? '' : String(raw); }); return result; }; const validateBatch = () => { const rows = Array.isArray(csvData.rows) ? csvData.rows : []; const validRows = []; const errors = []; rows.forEach((rowObj) => { const candidateFields = buildFieldsFromCsvRow(rowObj); const rowErrors = validateTemplate(state.type, candidateFields, pageT); if (rowErrors.length) { errors.push({ row: rowObj.__row, field: 'template', message: rowErrors[0] }); } else { validRows.push({ row: rowObj, fields: candidateFields }); } }); const report = { rows: rows.length, validRows, errors, }; setBatchReport(report); return report; }; const generateBatch = async () => { const report = validateBatch(); if (!report.rows) { setStatusMessage('No CSV rows to process.', 'text-amber-600'); return; } if (batchMode === 'stop_first' && report.errors.length) { setStatusMessage(report.errors[0].message, 'text-rose-600'); return; } const targets = report.validRows; if (!targets.length) { setStatusMessage('No valid rows to generate.', 'text-amber-600'); return; } batchCancelRef.current = false; setBatchProgress({ active: true, processed: 0, total: targets.length }); try { const JSZipRef = await ensureJsZip(); const zip = new JSZipRef(); for (let index = 0; index < targets.length; index += 1) { if (batchCancelRef.current) { break; } const item = targets[index]; const generatedPayload = buildPayload(state.type, item.fields, state.settings); const format = state.settings.exportFormat; const blobResult = await createQrBlob(generatedPayload, { ...state, fields: item.fields }, format); const fileNameRaw = String(item.row.filename || '').trim(); const safeName = fileNameRaw ? fileNameRaw.replace(/[^a-zA-Z0-9._-]/g, '_') : ''; const fileName = safeName || `qr_${item.row.__row}_${safeHash(generatedPayload)}.${blobResult.ext}`; zip.file(fileName, blobResult.blob); setBatchProgress((prev) => ({ ...prev, processed: index + 1 })); } if (batchCancelRef.current) { zip.file('PARTIAL_GENERATION.txt', 'Generation cancelled by user. ZIP contains partial output.'); } const zipBlob = await zip.generateAsync({ type: 'blob' }); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const suffix = batchCancelRef.current ? '_partial' : ''; downloadBlob(zipBlob, `qr-batch-${stamp}${suffix}.zip`); setStatusMessage( batchCancelRef.current ? pageT.statusBatchPartial : pageT.statusBatchDone, batchCancelRef.current ? 'text-amber-600' : 'text-emerald-600' ); } catch (error) { setStatusMessage((error && error.message) || pageT.statusError, 'text-rose-600'); } finally { setBatchProgress((prev) => ({ ...prev, active: false })); } }; const onCancelBatch = () => { batchCancelRef.current = true; setStatusMessage(pageT.statusBatchCancelled, 'text-amber-600'); }; const renderTypeFields = () => { const fields = state.fields; const onField = (key, value) => { setState((prev) => ({ ...prev, fields: { ...prev.fields, [key]: value, }, })); }; if (state.type === 'url') { return ( ); } if (state.type === 'url_utm') { return (
{['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'].map((key) => ( ))}
); } if (state.type === 'wifi') { return (
); } if (state.type === 'vcard') { const keys = ['fn', 'tel', 'email', 'org', 'title', 'url', 'adr', 'note']; return (
{keys.map((key) => (