Files
ladill-qr-plus/resources/views/qr-codes/partials/qr-customizer-script.blade.php
T
isaaccladandCursor cd6571f199
Deploy Ladill QR Plus / deploy (push) Failing after 1s
Extract Ladill QR Plus as standalone app at qr.ladill.com.
Utility QR types only (URL, WiFi, link list, business, app download) with
SSO, Billing API integration, public /q resolver, and qr-plus:import for
platform migration. vCard and commerce types stay on the platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:31:24 +00:00

615 lines
28 KiB
PHP

@once('qr-customizer-alpine')
<script>
(function () {
const registerQrCustomizer = () => {
Alpine.data('qrCustomizer', (config) => ({
previewUrl: config.previewUrl,
shortCode: config.shortCode || 'preview',
qrData: config.qrData || 'https://ladill.com',
existingLogoPath: config.existingLogoPath || null,
hasExistingLogo: Boolean(config.hasExistingLogo),
removeLogo: false,
previewLoading: false,
previewTimer: null,
showPreviewModal: false,
openSection: config.openSection || 'body',
balance: config.balance ?? null,
price: config.price ?? null,
topupModalId: config.topupModalId ?? null,
canonicalSyncUrl: config.canonicalSyncUrl ?? null,
csrf: config.csrf ?? null,
_canonicalSynced: false,
type: config.type ?? 'url',
fg: config.style.foreground,
bg: config.style.background,
ecc: config.style.error_correction,
margin: Number(config.style.margin),
moduleStyle: config.style.module_style,
finderOuter: config.style.finder_outer,
finderInner: config.style.finder_inner,
frameStyle: config.style.frame_style,
frameText: config.style.frame_text || '',
frameColor: config.style.frame_color || '#000000',
scale: Number(config.style.scale),
gradientType: config.style.gradient_type || 'none',
gradientColor1: config.style.gradient_color1 || '#000000',
gradientColor2: config.style.gradient_color2 || '#7c3aed',
gradientRotation: Number(config.style.gradient_rotation ?? 45),
logoSize: Number(config.style.logo_size ?? 0.3),
logoMargin: Number(config.style.logo_margin ?? 5),
logoWhiteBg: Boolean(config.style.logo_white_bg),
logoShape: config.style.logo_shape || 'none',
_rawLogoFile: null,
_processedLogoUrl: null,
init() {
[
'fg', 'bg', 'ecc', 'margin', 'moduleStyle',
'finderOuter', 'finderInner', 'frameStyle', 'frameText', 'frameColor', 'scale',
'gradientType', 'gradientColor1', 'gradientColor2', 'gradientRotation',
'logoSize', 'logoMargin',
].forEach((field) => {
this.$watch(field, () => this.schedulePreview());
});
// These require canvas re-processing before preview
['logoWhiteBg', 'logoShape'].forEach((field) => {
this.$watch(field, () => this._reprocessLogo());
});
this.schedulePreview();
if (config.existingLogoUrl) {
this._loadExistingLogo(config.existingLogoUrl);
}
this.$watch('showPreviewModal', (val) => {
if (val) this.schedulePreview();
});
},
toggleSection(id) {
this.openSection = this.openSection === id ? null : id;
},
schedulePreview() {
clearTimeout(this.previewTimer);
this.previewTimer = setTimeout(() => this.refreshPreview(), 200);
},
async onLogoChange() {
const input = this.$refs.logoInput;
this._rawLogoFile = input?.files?.[0] || null;
if (this._rawLogoFile) {
await this._reprocessLogo();
} else {
this._processedLogoUrl = null;
this.schedulePreview();
}
},
async _reprocessLogo() {
if (!this._rawLogoFile) return;
const result = await this._processLogoImage(this._rawLogoFile);
if (result) {
this._processedLogoUrl = result;
this.schedulePreview();
}
},
// Load existing logo from data URI (edit view) and apply canvas processing
_loadExistingLogo(dataUri) {
fetch(dataUri)
.then(r => r.blob())
.then(blob => {
this._rawLogoFile = new File([blob], 'logo', { type: blob.type || 'image/png' });
return this._processLogoImage(this._rawLogoFile);
})
.then(result => {
if (result) {
this._processedLogoUrl = result;
this.schedulePreview();
}
})
.catch(() => {
this._processedLogoUrl = dataUri;
this.schedulePreview();
});
},
// Apply shape clipping and/or white background to logo via canvas.
// For circle/rounded shapes a center-square crop is used so the shape looks balanced.
// For no-shape logos the natural aspect ratio is preserved.
_processLogoImage(file) {
return new Promise((resolve) => {
const img = new Image();
const raw = URL.createObjectURL(file);
img.onload = () => {
const needsSquareCrop = this.logoShape !== 'none';
const sw = img.naturalWidth;
const sh = img.naturalHeight;
let cw, ch, sx, sy, sSize;
if (needsSquareCrop) {
// Crop to center square for circle/rounded shapes
sSize = Math.min(sw, sh);
sx = (sw - sSize) / 2;
sy = (sh - sSize) / 2;
cw = ch = 300;
} else {
// Preserve natural aspect ratio
const MAX = 600;
const scale = Math.min(1, MAX / Math.max(sw, sh));
cw = Math.round(sw * scale);
ch = Math.round(sh * scale);
sx = sy = 0;
sSize = null;
}
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (needsSquareCrop) {
ctx.beginPath();
if (this.logoShape === 'circle') {
ctx.arc(cw / 2, ch / 2, cw / 2, 0, Math.PI * 2);
} else {
// Rounded — 20% corner radius
const r = cw * 0.2;
ctx.moveTo(r, 0);
ctx.lineTo(cw - r, 0);
ctx.arcTo(cw, 0, cw, r, r);
ctx.lineTo(cw, ch - r);
ctx.arcTo(cw, ch, cw - r, ch, r);
ctx.lineTo(r, ch);
ctx.arcTo(0, ch, 0, ch - r, r);
ctx.lineTo(0, r);
ctx.arcTo(0, 0, r, 0, r);
ctx.closePath();
}
ctx.clip();
}
if (this.logoWhiteBg) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cw, ch);
}
if (sSize !== null) {
ctx.drawImage(img, sx, sy, sSize, sSize, 0, 0, cw, ch);
} else {
ctx.drawImage(img, 0, 0, cw, ch);
}
URL.revokeObjectURL(raw);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => {
URL.revokeObjectURL(raw);
resolve(null);
};
img.src = raw;
});
},
setStylePreset(preset) {
const presets = {
classic: {
fg: '#000000', bg: '#ffffff', moduleStyle: 'square',
finderOuter: 'square', finderInner: 'square',
frameStyle: 'none', ecc: 'M', margin: 4,
gradientType: 'none',
},
rounded: {
fg: '#111827', bg: '#ffffff', moduleStyle: 'square',
finderOuter: 'rounded', finderInner: 'square',
frameStyle: 'scan_me', ecc: 'Q', margin: 4,
gradientType: 'none',
},
branded: {
fg: '#1d4ed8', bg: '#ffffff', moduleStyle: 'square',
finderOuter: 'rounded', finderInner: 'square',
frameStyle: 'tap_to_scan', ecc: 'Q', margin: 4,
gradientType: 'none',
},
dots: {
fg: '#0f172a', bg: '#ffffff', moduleStyle: 'dots',
finderOuter: 'square', finderInner: 'dot',
frameStyle: 'scan_me', ecc: 'H', margin: 5,
gradientType: 'none',
},
};
Object.assign(this, presets[preset] || presets.classic);
this.schedulePreview();
},
setEyePreset(preset) {
const presets = {
square: { finderOuter: 'square', finderInner: 'square' },
rounded: { finderOuter: 'rounded', finderInner: 'square' },
target: { finderOuter: 'circle', finderInner: 'dot' },
};
Object.assign(this, presets[preset] || presets.square);
this.schedulePreview();
},
checkBalance(event) {
if (this.balance === null || this.price === null || !this.topupModalId) {
return;
}
if (this.balance <= 0 || this.balance < this.price) {
event?.preventDefault();
window.dispatchEvent(new CustomEvent('open-modal', {
detail: this.topupModalId,
bubbles: true,
}));
}
},
submitOrTopup(event) {
if (this.balance <= 0 || this.balance < this.price) {
event?.preventDefault();
if (this.topupModalId) {
window.dispatchEvent(new CustomEvent('open-modal', {
detail: this.topupModalId,
bubbles: true,
}));
}
return;
}
if (event?.type === 'submit') {
return;
}
const form = this.$el.tagName === 'FORM' ? this.$el : this.$refs.createForm;
form?.requestSubmit();
},
_buildGradient() {
if (this.gradientType === 'none') return null;
return {
type: this.gradientType,
rotation: this.gradientType === 'linear' ? (Math.PI * this.gradientRotation / 180) : 0,
colorStops: [
{ offset: 0, color: this.gradientColor1 },
{ offset: 1, color: this.gradientColor2 },
],
};
},
_buildQrOptions(width, height) {
const dotsTypeMap = { square: 'square', dots: 'dots' };
const cornersSquareMap = { square: 'square', rounded: 'extra-rounded', circle: 'dot' };
// 'rounded' maps to 'square'; SVG post-processing applies the actual corner radius
const cornersDotMap = { square: 'square', rounded: 'square', dot: 'dot' };
const gradient = this._buildGradient();
const options = {
width,
height,
type: 'svg',
data: this.qrData,
margin: Math.round(Number(this.margin) * 4),
qrOptions: { errorCorrectionLevel: this.ecc || 'M' },
dotsOptions: {
type: dotsTypeMap[this.moduleStyle] || 'square',
...(gradient ? { gradient } : { color: this.fg }),
},
cornersSquareOptions: {
type: cornersSquareMap[this.finderOuter] || 'square',
...(gradient ? { gradient } : { color: this.fg }),
},
cornersDotOptions: {
type: cornersDotMap[this.finderInner] || 'square',
...(gradient ? { gradient } : { color: this.fg }),
},
backgroundOptions: { color: this.bg },
};
if (this._processedLogoUrl && !this.removeLogo) {
options.image = this._processedLogoUrl;
options.imageOptions = {
crossOrigin: 'anonymous',
margin: this.logoMargin,
imageSize: this.logoSize,
hideBackgroundDots: true,
};
}
return options;
},
// Post-process SVG to give corner inner dots rounded corners.
// qr-code-styling renders square cornersDot as <rect> elements inside <clipPath> in <defs>.
// Adding rx/ry directly to those rects rounds the clip mask → the visible dot appears rounded.
_applyRoundedInnerDot(container) {
const svg = container.querySelector('svg');
if (!svg) return;
const defs = svg.querySelector('defs');
if (!defs) { this._applyRoundedInnerDotFallback(container); return; }
// Collect all square <rect> elements (width === height) inside <clipPath> elements,
// grouped by their size value.
const groups = new Map();
defs.querySelectorAll('clipPath rect').forEach(rect => {
const w = rect.getAttribute('width');
const h = rect.getAttribute('height');
if (w && h && w === h) {
if (!groups.has(w)) groups.set(w, []);
groups.get(w).push(rect);
}
});
// Find the group of exactly 3 with the smallest size — that is the inner finder dot
// (one rect per finder eye; outer ring uses <path>, individual modules form much larger groups).
let targetRects = null, minSize = Infinity;
for (const [sizeStr, rects] of groups) {
if (rects.length !== 3) continue;
const size = parseFloat(sizeStr);
if (size < minSize) { minSize = size; targetRects = rects; }
}
if (!targetRects) { this._applyRoundedInnerDotFallback(container); return; }
const r = (minSize * 0.28).toFixed(3);
for (const rect of targetRects) {
rect.setAttribute('rx', r);
rect.setAttribute('ry', r);
}
},
// Fallback: use qrcode-generator to compute finder inner dot pixel positions, then
// overdraw rounded SVG <rect> elements at those positions.
_applyRoundedInnerDotFallback(container) {
const svg = container.querySelector('svg');
if (!svg || typeof window.qrcode === 'undefined') return;
try {
const qr = window.qrcode(0, this.ecc || 'M');
qr.addData(this.qrData);
qr.make();
const n = qr.getModuleCount();
const svgW = svg.viewBox?.baseVal?.width || parseFloat(svg.getAttribute('width') || '500');
const marginPx = Math.round(Number(this.margin) * 4);
const modulePx = (svgW - 2 * marginPx) / n;
const dotSize = modulePx * 3;
const r = dotSize * 0.28;
const fill = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
const NS = 'http://www.w3.org/2000/svg';
for (const [row, col] of [[2, 2], [2, n - 5], [n - 5, 2]]) {
const x = marginPx + col * modulePx;
const y = marginPx + row * modulePx;
// Cover the square dot with the background color
const eraser = document.createElementNS(NS, 'rect');
eraser.setAttribute('x', x); eraser.setAttribute('y', y);
eraser.setAttribute('width', dotSize); eraser.setAttribute('height', dotSize);
eraser.setAttribute('fill', this.bg);
svg.appendChild(eraser);
// Draw rounded corner dot
const dot = document.createElementNS(NS, 'rect');
dot.setAttribute('x', x); dot.setAttribute('y', y);
dot.setAttribute('width', dotSize); dot.setAttribute('height', dotSize);
dot.setAttribute('rx', r); dot.setAttribute('ry', r);
dot.setAttribute('fill', fill);
svg.appendChild(dot);
}
} catch (_) { /* silent */ }
},
refreshPreview() {
if (typeof window.QRCodeStyling === 'undefined') return;
this.previewLoading = true;
try {
const container = this.$refs.qrPreview;
if (container) {
container.innerHTML = '';
new window.QRCodeStyling(this._buildQrOptions(500, 500)).append(container);
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(container);
}
const modalContainer = this.$refs.qrPreviewModal;
if (modalContainer && this.showPreviewModal) {
modalContainer.innerHTML = '';
new window.QRCodeStyling(this._buildQrOptions(320, 320)).append(modalContainer);
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(modalContainer);
}
} finally {
this.previewLoading = false;
this._maybeSyncCanonical();
}
},
// Persist the exact rendered QR as the canonical SVG+PNG (single source of
// truth) so downloads match the preview. Only on pages with a real /q code
// (canonicalSyncUrl set — i.e. the show page), once per load.
_maybeSyncCanonical() {
if (!this.canonicalSyncUrl || this._canonicalSynced) return;
if (typeof window.QRCodeStyling === 'undefined') return;
const svgEl = this.$refs.qrPreview?.querySelector('svg');
if (!svgEl) return;
this._canonicalSynced = true;
// Defer so finder-dot post-processing + DOM settle before serializing.
setTimeout(() => this.syncCanonical(), 300);
},
async syncCanonical() {
try {
const svgEl = this.$refs.qrPreview?.querySelector('svg');
if (!svgEl) return;
const inner = new XMLSerializer().serializeToString(svgEl);
const finalSvg = this._buildFramedSvg(inner);
const png = await this._svgToPng(finalSvg);
if (!png) return;
const fd = new FormData();
fd.append('svg', finalSvg);
fd.append('png', png);
await fetch(this.canonicalSyncUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': this.csrf || '', 'Accept': 'application/json' },
body: fd,
});
} catch (_) { /* non-fatal: server fallback render remains */ }
},
_xmlEscape(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
// Composite the CSS preview frame (thin border / SCAN ME label / TAP TO
// SCAN pill) into a single vector SVG, with the QR nested as vector — so
// downloads include the frame exactly like the preview. No frame => QR as-is.
_buildFramedSvg(qrSvg) {
const fs = this.frameStyle;
if (!fs || fs === 'none') return qrSvg;
const color = this.frameColor || '#000000';
const Q = 1000, pad = 72, radius = 56;
const W = Q + pad * 2;
// Nest the QR vector: ensure a viewBox, drop intrinsic width/height, place it.
const wm = qrSvg.match(/<svg\b[^>]*?\swidth\s*=\s*"([\d.]+)/i);
const hm = qrSvg.match(/<svg\b[^>]*?\sheight\s*=\s*"([\d.]+)/i);
const ow = wm ? wm[1] : '500';
const oh = hm ? hm[1] : '500';
let qr = qrSvg;
if (!/viewBox\s*=/i.test(qr)) {
qr = qr.replace(/<svg\b/i, `<svg viewBox="0 0 ${ow} ${oh}"`);
}
qr = qr
.replace(/(<svg\b[^>]*?)\swidth\s*=\s*"[^"]*"/i, '$1')
.replace(/(<svg\b[^>]*?)\sheight\s*=\s*"[^"]*"/i, '$1')
.replace(/<svg\b/i, `<svg x="${pad}" y="${pad}" width="${Q}" height="${Q}"`);
let labelH = 0, labelMarkup = '', border = '';
if (fs === 'scan_me') {
labelH = 124;
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'SCAN ME').toUpperCase();
labelMarkup =
`<line x1="${pad}" y1="${Q + pad + 26}" x2="${W - pad}" y2="${Q + pad + 26}" stroke="${color}" stroke-opacity="0.25" stroke-width="2"/>` +
`<text x="${W / 2}" y="${Q + pad + 86}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="700" letter-spacing="7" fill="${color}">${this._xmlEscape(txt)}</text>`;
} else if (fs === 'tap_to_scan') {
labelH = 142;
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'TAP TO SCAN').toUpperCase();
const pillH = 78;
const pillW = Math.min(W - pad * 2, 160 + txt.length * 24);
const pillX = (W - pillW) / 2, pillY = Q + pad + 30;
labelMarkup =
`<rect x="${pillX}" y="${pillY}" width="${pillW}" height="${pillH}" rx="${pillH / 2}" fill="${color}"/>` +
`<text x="${W / 2}" y="${pillY + pillH / 2 + 13}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="36" font-weight="700" letter-spacing="5" fill="${this.frameTextColor}">${this._xmlEscape(txt)}</text>`;
}
const H = Q + pad * 2 + labelH;
if (fs === 'thin') {
border = `<rect x="11" y="11" width="${W - 22}" height="${H - 22}" rx="${radius - 6}" fill="none" stroke="${color}" stroke-width="18"/>`;
}
// No XML prolog here — the server's sanitizeSvg prepends it (a literal
// XML prolog in a Blade file would be read as a PHP open tag).
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
`<rect x="0" y="0" width="${W}" height="${H}" rx="${radius}" fill="#ffffff"/>` +
border + qr + labelMarkup +
'</svg>';
},
_svgToPng(svgString) {
return new Promise((resolve) => {
try {
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
try {
const w = img.naturalWidth || 1000;
const h = img.naturalHeight || 1000;
const f = Math.max(1, 1000 / Math.max(w, h)); // upscale small to ~1000 for crisp print
const cw = Math.round(w * f), ch = Math.round(h * f);
const c = document.createElement('canvas');
c.width = cw; c.height = ch;
const ctx = c.getContext('2d');
ctx.fillStyle = (this.frameStyle && this.frameStyle !== 'none') ? '#ffffff' : (this.bg || '#ffffff');
ctx.fillRect(0, 0, cw, ch);
ctx.drawImage(img, 0, 0, cw, ch);
URL.revokeObjectURL(url);
resolve(c.toDataURL('image/png'));
} catch (_) { URL.revokeObjectURL(url); resolve(null); }
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
} catch (_) { resolve(null); }
});
},
// Returns black or white depending on frameColor luminance, for readable CTA text
get frameTextColor() {
return this._hexToLuminance(this.frameColor) > 0.35 ? '#000000' : '#ffffff';
},
// ── Scanability validation ────────────────────────────────────────
get scanability() {
let score = 100;
const warnings = [];
// Contrast check — use gradient start colour when gradient is active
const effectiveFg = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
const ratio = this._contrastRatio(effectiveFg, this.bg);
if (ratio < 2.0) {
score -= 50;
warnings.push('Very low contrast — code will likely fail to scan.');
} else if (ratio < 3.0) {
score -= 30;
warnings.push('Low contrast between foreground and background.');
} else if (ratio < 4.5) {
score -= 10;
warnings.push('Contrast is acceptable but could be improved.');
}
// Logo size checks
const hasLogo = Boolean(this._processedLogoUrl) && !this.removeLogo;
if (hasLogo) {
if (this.logoSize > 0.40) {
score -= 25;
warnings.push('Logo is too large and may block scanning.');
} else if (this.logoSize > 0.30) {
score -= 10;
warnings.push('Large logo — use High (H) error correction.');
}
if (this.logoSize > 0.25 && this.ecc === 'L') {
score -= 15;
warnings.push('Logo requires at least Medium (M) error correction.');
}
}
// Extreme customisation warning
const hasGradient = this.gradientType !== 'none';
const complexEyes = this.finderOuter === 'circle' || this.finderInner === 'dot';
if (hasLogo && hasGradient && complexEyes && this.ecc === 'L') {
score -= 10;
warnings.push('Many effects combined — consider higher error correction.');
}
score = Math.max(0, score);
let label, level;
if (score >= 80) { label = 'Excellent'; level = 'excellent'; }
else if (score >= 60) { label = 'Good'; level = 'good'; }
else { label = 'Warning'; level = 'warning'; }
return { score, label, level, warnings };
},
_hexToLuminance(hex) {
hex = (hex || '#000000').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
if (hex.length !== 6) return 0;
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
},
_contrastRatio(hex1, hex2) {
const l1 = this._hexToLuminance(hex1);
const l2 = this._hexToLuminance(hex2);
const light = Math.max(l1, l2);
const dark = Math.min(l1, l2);
return (light + 0.05) / (dark + 0.05);
},
}));
};
if (window.Alpine) {
registerQrCustomizer();
} else {
document.addEventListener('alpine:init', registerQrCustomizer);
}
})();
</script>
@endonce