Deploy Ladill POS / deploy (push) Successful in 43s
Drop the soft shadow and letterboxing so the promo photo edge-to-edge covers the hero card via object-cover.
489 lines
28 KiB
PHP
489 lines
28 KiB
PHP
<!DOCTYPE html>
|
||
<html lang="en" class="h-full">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
<title>Customer display · {{ $state['location_name'] ?? 'POS' }}</title>
|
||
@include('partials.favicon')
|
||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||
<style>
|
||
[x-cloak] { display: none !important; }
|
||
html, body { overflow: hidden; }
|
||
</style>
|
||
</head>
|
||
{{-- Light kiosk shell aligned with Frontdesk reception kiosk. --}}
|
||
<body class="h-full bg-slate-100 font-sans text-slate-900 antialiased"
|
||
x-data="customerDisplay(@js([
|
||
'initial' => $state,
|
||
'stateUrl' => $stateUrl,
|
||
'streamUrl' => $streamUrl,
|
||
'actionUrl' => $actionUrl,
|
||
'csrf' => csrf_token(),
|
||
]))">
|
||
<div class="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||
<div class="absolute -left-24 top-0 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl"></div>
|
||
<div class="absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-200/40 blur-3xl"></div>
|
||
</div>
|
||
<div class="fixed inset-x-0 top-0 z-20 h-1.5 bg-gradient-to-r from-indigo-600 to-violet-600"></div>
|
||
|
||
<div class="relative flex h-full min-h-dvh flex-col">
|
||
{{-- Brand bar — logo uses object-contain (never square-crop) like Frontdesk kiosk-brand --}}
|
||
<header class="flex items-center justify-between gap-4 border-b border-slate-200/80 bg-white/80 px-6 py-4 backdrop-blur-sm sm:px-10">
|
||
<div class="flex min-w-0 items-center gap-3">
|
||
<template x-if="state.brand?.logo_url">
|
||
<img :src="state.brand.logo_url"
|
||
:alt="state.location_name || 'Store'"
|
||
class="h-8 w-auto max-w-[220px] shrink-0 object-contain object-left sm:h-9 sm:max-w-[240px]">
|
||
</template>
|
||
<div class="min-w-0" x-show="!state.brand?.logo_url || state.brand?.header">
|
||
<p class="truncate text-lg font-semibold tracking-tight text-slate-900"
|
||
x-show="!state.brand?.logo_url"
|
||
x-text="state.location_name"></p>
|
||
<p class="truncate text-xs text-slate-500" x-show="state.brand?.header" x-text="state.brand.header"></p>
|
||
</div>
|
||
</div>
|
||
<div class="shrink-0 text-right text-xs font-medium tabular-nums text-slate-500">
|
||
<span x-text="clock"></span>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="relative flex flex-1 flex-col overflow-hidden">
|
||
{{-- IDLE / PROMO — hero image when set; otherwise centered welcome --}}
|
||
<section x-show="isPhase('idle') || isPhase('promo')" x-cloak
|
||
class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
{{-- Promo with image: large hero + copy (side-by-side on landscape kiosks) --}}
|
||
<div x-show="!!state.promo?.image_url"
|
||
class="flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-6 lg:flex-row lg:items-stretch lg:gap-8 lg:p-8">
|
||
{{-- Image fills the card (object-cover), no shadow --}}
|
||
<div class="relative min-h-0 max-h-[52vh] flex-[1.35] overflow-hidden rounded-[1.75rem] bg-slate-100 ring-1 ring-slate-200/90 sm:max-h-[56vh] sm:rounded-[2rem] lg:max-h-none lg:flex-1">
|
||
<img :src="state.promo?.image_url"
|
||
:alt="state.promo?.headline || state.location_name || 'Promo'"
|
||
class="absolute inset-0 z-[1] h-full w-full object-cover object-center">
|
||
</div>
|
||
<div class="flex shrink-0 flex-col justify-center gap-3.5 px-1 pb-1 text-center sm:gap-4 sm:px-2 lg:w-[min(36%,24rem)] lg:px-1 lg:pb-0 lg:text-left xl:w-[min(34%,26rem)]">
|
||
<div>
|
||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600">Welcome</p>
|
||
<h1 class="mt-2 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl lg:text-[2.25rem] lg:leading-[1.15]"
|
||
x-text="state.promo?.headline || state.location_name"></h1>
|
||
<p class="mx-auto mt-3 max-w-md text-pretty text-base leading-relaxed text-slate-500 lg:mx-0 lg:text-lg"
|
||
x-text="state.promo?.body || 'Thank you for shopping with us.'"></p>
|
||
</div>
|
||
<div class="mx-auto h-px w-12 bg-slate-200/90 lg:mx-0" aria-hidden="true"></div>
|
||
<p class="text-sm text-slate-400">Your order will appear here</p>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- No promo image: centered welcome --}}
|
||
<div x-show="!state.promo?.image_url"
|
||
class="flex flex-1 flex-col items-center justify-center gap-6 px-8 text-center">
|
||
<div class="max-w-xl">
|
||
<p class="text-sm font-semibold uppercase tracking-wider text-indigo-600">Welcome</p>
|
||
<h1 class="mt-3 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl"
|
||
x-text="state.promo?.headline || state.location_name"></h1>
|
||
<p class="mx-auto mt-4 text-lg text-slate-500"
|
||
x-text="state.promo?.body || 'Thank you for shopping with us.'"></p>
|
||
</div>
|
||
<p class="text-sm text-slate-400">Your order will appear here</p>
|
||
</div>
|
||
</section>
|
||
|
||
{{-- CART + payment / tip / signature --}}
|
||
<section x-show="isPhase('cart') || isPhase('payment') || isPhase('tip') || isPhase('signature')" x-cloak
|
||
class="flex flex-1 flex-col overflow-hidden lg:flex-row">
|
||
<div class="flex flex-1 flex-col overflow-hidden px-6 py-5 sm:px-10">
|
||
<div class="mb-4 flex items-end justify-between gap-3">
|
||
<div>
|
||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Order summary</p>
|
||
<p class="mt-0.5 text-sm text-slate-500" x-show="state.customer_name">
|
||
For <span class="font-medium text-slate-800" x-text="state.customer_name"></span>
|
||
</p>
|
||
</div>
|
||
<p class="text-sm tabular-nums text-slate-400" x-show="state.sale_reference" x-text="state.sale_reference"></p>
|
||
</div>
|
||
|
||
<ul class="flex-1 space-y-2 overflow-y-auto pr-1">
|
||
<template x-for="(line, i) in (state.lines || [])" :key="i">
|
||
<li class="flex items-start justify-between gap-4 rounded-2xl border border-slate-200/80 bg-white px-4 py-3 shadow-sm">
|
||
<div class="min-w-0">
|
||
<p class="font-medium text-slate-900">
|
||
<span class="text-slate-400" x-text="line.quantity + '×'"></span>
|
||
<span x-text="line.name"></span>
|
||
</p>
|
||
<p class="mt-0.5 text-xs text-slate-500" x-show="line.note" x-text="line.note"></p>
|
||
<p class="mt-0.5 text-xs font-medium text-emerald-600" x-show="line.discount_minor > 0"
|
||
x-text="'Discount −' + money(line.discount_minor)"></p>
|
||
</div>
|
||
<p class="shrink-0 font-semibold tabular-nums text-slate-900" x-text="money(line.line_total_minor)"></p>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
|
||
<div class="mt-4 space-y-1.5 rounded-2xl border border-slate-200/80 bg-white px-4 py-4 text-sm shadow-sm">
|
||
<div class="flex justify-between text-slate-600">
|
||
<span>Subtotal</span>
|
||
<span class="tabular-nums" x-text="money(state.subtotal_minor)"></span>
|
||
</div>
|
||
<div class="flex justify-between text-emerald-600" x-show="state.discount_minor > 0">
|
||
<span>Discounts</span>
|
||
<span class="tabular-nums" x-text="'−' + money(state.discount_minor)"></span>
|
||
</div>
|
||
<div class="flex justify-between text-slate-600" x-show="state.tax_minor > 0">
|
||
<span>Tax</span>
|
||
<span class="tabular-nums" x-text="money(state.tax_minor)"></span>
|
||
</div>
|
||
<div class="flex justify-between text-slate-600" x-show="(state.tip?.selected_minor || state.tip_minor) > 0">
|
||
<span>Tip</span>
|
||
<span class="tabular-nums" x-text="money(state.tip?.selected_minor || state.tip_minor)"></span>
|
||
</div>
|
||
<div class="flex justify-between border-t border-slate-100 pt-3 text-2xl font-bold tracking-tight text-slate-900">
|
||
<span>Total</span>
|
||
<span class="tabular-nums text-indigo-600" x-text="money(grandTotal())"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="state.loyalty" x-cloak
|
||
class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
|
||
<p class="font-semibold text-amber-900" x-text="state.loyalty?.label || 'Loyalty'"></p>
|
||
<div class="mt-1 flex flex-wrap gap-4 text-amber-800/90">
|
||
<span x-show="state.loyalty?.points_balance != null">
|
||
Balance: <strong x-text="state.loyalty.points_balance"></strong> pts
|
||
</span>
|
||
<span x-show="state.loyalty?.points_earn">
|
||
This visit: +<strong x-text="state.loyalty.points_earn"></strong>
|
||
</span>
|
||
<span x-show="state.loyalty?.points_redeem">
|
||
Redeeming: −<strong x-text="state.loyalty.points_redeem"></strong>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="flex w-full flex-col justify-center gap-5 border-t border-slate-200 bg-white/90 px-6 py-6 lg:w-[420px] lg:border-l lg:border-t-0 sm:px-8">
|
||
{{-- Payment --}}
|
||
<div x-show="isPhase('payment')" class="space-y-4">
|
||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Payment</p>
|
||
<p class="text-xl font-semibold text-slate-900" x-text="state.payment?.message || 'Please complete payment'"></p>
|
||
<p class="text-sm capitalize text-slate-500" x-show="state.payment?.status"
|
||
x-text="'Status: ' + (state.payment?.status || '')"></p>
|
||
|
||
<div class="flex flex-wrap gap-2" x-show="state.payment?.options?.length">
|
||
<template x-for="opt in (state.payment?.options || [])" :key="opt">
|
||
<button type="button" @click="choosePayment(opt)"
|
||
class="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:border-indigo-300 hover:bg-indigo-50"
|
||
x-text="opt"></button>
|
||
</template>
|
||
</div>
|
||
|
||
<div x-show="qrData()" class="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||
<img :src="qrImage(qrData())" alt="Payment QR" class="h-48 w-48">
|
||
<p class="text-center text-xs text-slate-500">Scan to pay with your phone</p>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Tip --}}
|
||
<div x-show="isPhase('tip')" class="space-y-4">
|
||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Add a tip</p>
|
||
<p class="text-xl font-semibold text-slate-900">How much would you like to tip?</p>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<template x-for="pct in (state.tip?.options_percent || [10,15,18,20])" :key="pct">
|
||
<button type="button" @click="selectTipPercent(pct)"
|
||
:class="state.tip?.selected_percent === pct
|
||
? 'border-indigo-500 bg-indigo-600 text-white ring-2 ring-indigo-200'
|
||
: 'border-slate-200 bg-white text-slate-900 hover:border-indigo-300 hover:bg-indigo-50'"
|
||
class="rounded-2xl border px-3 py-4 text-center shadow-sm transition">
|
||
<span class="block text-2xl font-bold" x-text="pct + '%'"></span>
|
||
<span class="mt-1 block text-xs opacity-80" x-text="money(tipAmount(pct))"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
<button type="button" @click="selectTipPercent(0)"
|
||
class="w-full rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-medium text-slate-600 shadow-sm hover:bg-slate-50">
|
||
No tip
|
||
</button>
|
||
<div x-show="state.tip?.custom_allowed" class="flex gap-2">
|
||
<input type="number" min="0" step="0.01" x-model="customTip"
|
||
placeholder="Custom amount"
|
||
class="flex-1 rounded-xl border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500">
|
||
<button type="button" @click="selectCustomTip()"
|
||
class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">Apply</button>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Signature --}}
|
||
<div x-show="isPhase('signature')" class="space-y-3">
|
||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Signature</p>
|
||
<p class="text-lg font-semibold text-slate-900" x-text="state.signature?.prompt || 'Please sign below'"></p>
|
||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||
<canvas x-ref="sigCanvas" class="h-48 w-full touch-none"
|
||
@pointerdown="sigStart($event)"
|
||
@pointermove="sigMove($event)"
|
||
@pointerup="sigEnd()"
|
||
@pointerleave="sigEnd()"></canvas>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button type="button" @click="clearSignature()"
|
||
class="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Clear</button>
|
||
<button type="button" @click="submitSignature()"
|
||
class="flex-1 rounded-xl bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">Done</button>
|
||
</div>
|
||
<p x-show="sigSaved" class="text-sm font-medium text-emerald-600">Signature saved — thank you.</p>
|
||
</div>
|
||
|
||
{{-- Cart-only helper --}}
|
||
<div x-show="isPhase('cart')" class="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 px-5 py-8 text-center">
|
||
<p class="text-base font-semibold text-slate-800">Please review your order</p>
|
||
<p class="mt-1 text-sm text-slate-500">Prices shown include any applied discounts</p>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
|
||
{{-- RECEIPT --}}
|
||
<section x-show="isPhase('receipt')" x-cloak
|
||
class="flex flex-1 flex-col items-center justify-center gap-5 px-8 text-center">
|
||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-3xl text-emerald-600 ring-1 ring-emerald-200">✓</div>
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-slate-900 sm:text-4xl" x-text="state.receipt?.message || 'Thank you!'"></h1>
|
||
<p class="mt-2 text-slate-500" x-show="state.receipt?.reference || state.sale_reference">
|
||
Receipt <span class="font-medium text-slate-800" x-text="state.receipt?.reference || state.sale_reference"></span>
|
||
</p>
|
||
<p class="mt-4 text-4xl font-bold tabular-nums text-indigo-600" x-text="money(state.receipt?.total_minor ?? state.total_minor)"></p>
|
||
</div>
|
||
|
||
<div x-show="state.receipt?.digital_url" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||
<img :src="qrImage(state.receipt.digital_url)" alt="Digital receipt QR" class="h-40 w-40">
|
||
<p class="mt-2 text-xs text-slate-500">Scan for digital receipt</p>
|
||
</div>
|
||
|
||
<div x-show="state.receipt?.email_prompt !== false" class="w-full max-w-md">
|
||
<p class="mb-2 text-sm text-slate-500">Email me a digital receipt</p>
|
||
<div class="flex gap-2">
|
||
<input type="email" x-model="receiptEmail" placeholder="you@email.com"
|
||
class="flex-1 rounded-xl border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500">
|
||
<button type="button" @click="sendReceiptEmail()"
|
||
class="rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500">Send</button>
|
||
</div>
|
||
<p x-show="emailSent" class="mt-2 text-sm font-medium text-emerald-600">Receipt emailed — check your inbox.</p>
|
||
<p x-show="emailError" class="mt-2 text-sm font-medium text-rose-600" x-text="emailError"></p>
|
||
</div>
|
||
|
||
<p class="text-xs text-slate-400" x-show="state.brand?.footer" x-text="state.brand.footer"></p>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<script>
|
||
function customerDisplay(config) {
|
||
return {
|
||
state: config.initial || {},
|
||
stateUrl: config.stateUrl,
|
||
streamUrl: config.streamUrl,
|
||
actionUrl: config.actionUrl,
|
||
csrf: config.csrf,
|
||
clock: '',
|
||
customTip: '',
|
||
receiptEmail: '',
|
||
emailSent: false,
|
||
emailError: '',
|
||
sigSaved: false,
|
||
drawing: false,
|
||
clockTimer: null,
|
||
pollTimer: null,
|
||
es: null,
|
||
|
||
init() {
|
||
this.tickClock();
|
||
this.clockTimer = setInterval(() => this.tickClock(), 1000);
|
||
this.connectStream();
|
||
this.pollTimer = setInterval(() => this.fetchState(), 4000);
|
||
this.$nextTick(() => this.resizeCanvas());
|
||
window.addEventListener('resize', () => this.resizeCanvas());
|
||
try {
|
||
this.bc = new BroadcastChannel('pos-customer-display');
|
||
this.bc.onmessage = (e) => {
|
||
if (e.data?.type === 'state' && e.data.state) {
|
||
this.state = e.data.state;
|
||
}
|
||
};
|
||
} catch (_) {}
|
||
},
|
||
|
||
destroy() {
|
||
clearInterval(this.clockTimer);
|
||
clearInterval(this.pollTimer);
|
||
if (this.es) this.es.close();
|
||
try { this.bc?.close(); } catch (_) {}
|
||
},
|
||
|
||
tickClock() {
|
||
this.clock = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
},
|
||
|
||
isPhase(name) {
|
||
return (this.state.phase || 'idle') === name;
|
||
},
|
||
|
||
money(minor) {
|
||
const cur = this.state.currency || 'GHS';
|
||
const n = (Number(minor) || 0) / 100;
|
||
return cur + ' ' + n.toFixed(2);
|
||
},
|
||
|
||
grandTotal() {
|
||
const base = Number(this.state.total_minor) || 0;
|
||
const tipInTotal = Number(this.state.tip_minor) || 0;
|
||
const tipSelected = Number(this.state.tip?.selected_minor) || 0;
|
||
// Avoid double-counting when the server already baked tip into total_minor.
|
||
if (tipInTotal > 0) {
|
||
return base;
|
||
}
|
||
return base + tipSelected;
|
||
},
|
||
|
||
tipAmount(pct) {
|
||
const base = Number(this.state.tip?.base_minor) || Number(this.state.total_minor) || 0;
|
||
return Math.round(base * (Number(pct) / 100));
|
||
},
|
||
|
||
qrData() {
|
||
return this.state.payment?.qr_url || this.state.payment?.checkout_url || null;
|
||
},
|
||
|
||
qrImage(data) {
|
||
if (!data) return '';
|
||
return 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=' + encodeURIComponent(data);
|
||
},
|
||
|
||
connectStream() {
|
||
if (!this.streamUrl || typeof EventSource === 'undefined') return;
|
||
try {
|
||
this.es = new EventSource(this.streamUrl);
|
||
this.es.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
if (data && data.phase) this.state = data;
|
||
} catch (_) {}
|
||
};
|
||
this.es.onerror = () => {
|
||
this.es?.close();
|
||
setTimeout(() => this.connectStream(), 2000);
|
||
};
|
||
} catch (_) {}
|
||
},
|
||
|
||
async fetchState() {
|
||
try {
|
||
const res = await fetch(this.stateUrl, {
|
||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
});
|
||
if (!res.ok) return;
|
||
this.state = await res.json();
|
||
} catch (_) {}
|
||
},
|
||
|
||
async postAction(body) {
|
||
const res = await fetch(this.actionUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': this.csrf,
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (data.state) this.state = data.state;
|
||
return data;
|
||
},
|
||
|
||
async selectTipPercent(pct) {
|
||
const tip_minor = pct > 0 ? this.tipAmount(pct) : 0;
|
||
await this.postAction({ type: 'tip', tip_percent: pct, tip_minor });
|
||
},
|
||
|
||
async selectCustomTip() {
|
||
const amount = Math.round(parseFloat(this.customTip || 0) * 100);
|
||
if (amount < 0) return;
|
||
await this.postAction({ type: 'tip', tip_percent: null, tip_minor: amount });
|
||
},
|
||
|
||
async choosePayment(opt) {
|
||
await this.postAction({ type: 'payment_option', payment_option: opt });
|
||
},
|
||
|
||
async sendReceiptEmail() {
|
||
if (!this.receiptEmail) return;
|
||
this.emailSent = false;
|
||
this.emailError = '';
|
||
const data = await this.postAction({ type: 'receipt_email', email: this.receiptEmail });
|
||
if (data.email_sent) {
|
||
this.emailSent = true;
|
||
} else {
|
||
this.emailError = data.message || 'Could not send email.';
|
||
}
|
||
},
|
||
|
||
resizeCanvas() {
|
||
const c = this.$refs.sigCanvas;
|
||
if (!c) return;
|
||
const rect = c.getBoundingClientRect();
|
||
const ratio = window.devicePixelRatio || 1;
|
||
c.width = Math.floor(rect.width * ratio);
|
||
c.height = Math.floor(rect.height * ratio);
|
||
const ctx = c.getContext('2d');
|
||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
ctx.lineWidth = 2.5;
|
||
ctx.lineCap = 'round';
|
||
ctx.strokeStyle = '#0f172a';
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||
},
|
||
|
||
sigPoint(e) {
|
||
const c = this.$refs.sigCanvas;
|
||
const r = c.getBoundingClientRect();
|
||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||
},
|
||
|
||
sigStart(e) {
|
||
this.drawing = true;
|
||
const c = this.$refs.sigCanvas;
|
||
const ctx = c.getContext('2d');
|
||
const p = this.sigPoint(e);
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x, p.y);
|
||
c.setPointerCapture?.(e.pointerId);
|
||
},
|
||
|
||
sigMove(e) {
|
||
if (!this.drawing) return;
|
||
const ctx = this.$refs.sigCanvas.getContext('2d');
|
||
const p = this.sigPoint(e);
|
||
ctx.lineTo(p.x, p.y);
|
||
ctx.stroke();
|
||
},
|
||
|
||
sigEnd() {
|
||
this.drawing = false;
|
||
},
|
||
|
||
clearSignature() {
|
||
this.sigSaved = false;
|
||
this.resizeCanvas();
|
||
},
|
||
|
||
async submitSignature() {
|
||
const dataUrl = this.$refs.sigCanvas?.toDataURL('image/png');
|
||
if (!dataUrl) return;
|
||
await this.postAction({ type: 'signature', signature_data_url: dataUrl });
|
||
this.sigSaved = true;
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|