Files
isaaccladandCursor db66d99895 Initial Ladill Mini app — trader payment QRs without styling.
Lean control center at mini.ladill.com: payment QR CRUD, Paystack checkout with 5% fee settlement via Billing API, payments feed, and payouts. QR codes use a fixed black-and-white preset only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 18:43:39 +00:00

670 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,
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());
});
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();
},
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') 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