Deploy Ladill Events / deploy (push) Successful in 52s
Remove a duplicate preview ref that targeted a hidden card, enable mobile preview for programmes, and harden customizer init timing. Co-authored-by: Cursor <cursoragent@cursor.com>
676 lines
30 KiB
PHP
676 lines
30 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,
|
|
_qrLibTimer: null,
|
|
showPreviewModal: false,
|
|
openSection: config.openSection || 'body',
|
|
balance: config.balance ?? null,
|
|
price: config.price ?? null,
|
|
topupModalId: config.topupModalId ?? null,
|
|
topupUrl: config.topupUrl ?? null,
|
|
canonicalSyncUrl: config.canonicalSyncUrl ?? null,
|
|
csrf: config.csrf ?? null,
|
|
_canonicalSynced: false,
|
|
type: config.type ?? 'url',
|
|
wizard: Boolean(config.wizard),
|
|
step: config.wizard ? 1 : 0,
|
|
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());
|
|
});
|
|
|
|
if (config.existingLogoUrl) {
|
|
this._loadExistingLogo(config.existingLogoUrl);
|
|
}
|
|
|
|
this.$watch('showPreviewModal', (val) => {
|
|
if (val) this.schedulePreview();
|
|
});
|
|
|
|
if (this.wizard) {
|
|
this.$watch('step', () => this.$nextTick(() => this.schedulePreview()));
|
|
}
|
|
|
|
this.$nextTick(() => 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();
|
|
},
|
|
redirectTopup() {
|
|
if (this.topupUrl) {
|
|
window.location.href = this.topupUrl;
|
|
return true;
|
|
}
|
|
|
|
if (this.topupModalId) {
|
|
window.dispatchEvent(new CustomEvent('open-modal', {
|
|
detail: this.topupModalId,
|
|
bubbles: true,
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
checkBalance(event) {
|
|
if (this.balance === null || this.price === null) {
|
|
return;
|
|
}
|
|
|
|
if (this.balance <= 0 || this.balance < this.price) {
|
|
event?.preventDefault();
|
|
this.redirectTopup();
|
|
}
|
|
},
|
|
nextStep(event) {
|
|
if (!this.wizard) {
|
|
return;
|
|
}
|
|
if (this.step === 1 && !this.validateWizardStep1()) {
|
|
event?.preventDefault();
|
|
return;
|
|
}
|
|
if (this.step < 3) {
|
|
this.step++;
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
},
|
|
prevStep() {
|
|
if (this.wizard && this.step > 1) {
|
|
this.step--;
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
},
|
|
validateWizardStep1() {
|
|
const form = this.$refs.createForm;
|
|
const label = form?.querySelector('[name=label]');
|
|
if (!label?.value?.trim()) {
|
|
label?.focus();
|
|
label?.reportValidity?.();
|
|
return false;
|
|
}
|
|
return label.checkValidity?.() !== false;
|
|
},
|
|
wizardSubmit(event) {
|
|
if (this.wizard && this.step < 3) {
|
|
event?.preventDefault();
|
|
this.nextStep(event);
|
|
return;
|
|
}
|
|
this.submitOrTopup(event);
|
|
},
|
|
reviewLabel() {
|
|
return this.$refs.createForm?.querySelector('[name=label]')?.value?.trim() || '';
|
|
},
|
|
reviewEventName() {
|
|
const eventBlock = this.$refs.createForm?.querySelector('[data-event-name]');
|
|
return eventBlock?.value?.trim() || this.reviewLabel();
|
|
},
|
|
submitOrTopup(event) {
|
|
if (this.balance <= 0 || this.balance < this.price) {
|
|
event?.preventDefault();
|
|
this.redirectTopup();
|
|
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') {
|
|
clearTimeout(this._qrLibTimer);
|
|
this._qrLibTimer = setTimeout(() => this.refreshPreview(), 100);
|
|
return;
|
|
}
|
|
|
|
clearTimeout(this._qrLibTimer);
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
},
|
|
// 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);
|
|
},
|
|
}));
|
|
};
|
|
|
|
document.addEventListener('alpine:init', registerQrCustomizer);
|
|
})();
|
|
</script>
|
|
@endonce
|