Add dual-screen customer display for the register.
Deploy Ladill POS / deploy (push) Successful in 56s

Token-gated full-screen customer view shows order, payment QR, tips,
signature, receipt, and promo phases while the till keeps operator UI.
This commit is contained in:
isaacclad
2026-07-15 15:35:44 +00:00
parent 5e31bdf629
commit 38b7f96714
10 changed files with 1426 additions and 4 deletions
@@ -0,0 +1,439 @@
<!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>
<body class="h-full bg-slate-950 font-sans text-white antialiased"
x-data="customerDisplay(@js([
'initial' => $state,
'stateUrl' => $stateUrl,
'streamUrl' => $streamUrl,
'actionUrl' => $actionUrl,
'csrf' => csrf_token(),
]))">
<div class="flex h-full min-h-dvh flex-col">
{{-- Brand bar --}}
<header class="flex items-center justify-between gap-4 border-b border-white/10 px-6 py-4 sm:px-10">
<div class="flex items-center gap-3 min-w-0">
<template x-if="state.brand?.logo_url">
<img :src="state.brand.logo_url" alt="" class="h-10 w-10 rounded-xl object-cover bg-white/10">
</template>
<div class="min-w-0">
<p class="truncate text-lg font-semibold tracking-tight" x-text="state.location_name"></p>
<p class="text-xs text-slate-400" x-show="state.brand?.header" x-text="state.brand.header"></p>
</div>
</div>
<div class="text-right text-xs text-slate-500">
<span x-text="clock"></span>
</div>
</header>
<main class="relative flex flex-1 flex-col overflow-hidden">
{{-- IDLE / PROMO --}}
<section x-show="isPhase('idle') || isPhase('promo')" x-cloak
class="flex flex-1 flex-col items-center justify-center gap-6 px-8 text-center">
<template x-if="state.promo?.image_url">
<img :src="state.promo.image_url" alt="" class="h-28 w-28 rounded-3xl object-cover bg-white/10 shadow-2xl">
</template>
<div>
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl" x-text="state.promo?.headline || state.location_name"></h1>
<p class="mx-auto mt-4 max-w-xl text-lg text-slate-300" x-text="state.promo?.body || 'Welcome'"></p>
</div>
<p class="text-sm text-slate-500">Your order will appear here</p>
</section>
{{-- CART + shared totals for payment / tip --}}
<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-3 flex items-end justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-300">Order summary</p>
<p class="text-sm text-slate-400" x-show="state.customer_name">
For <span class="text-white" x-text="state.customer_name"></span>
</p>
</div>
<p class="text-sm text-slate-500" 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 bg-white/5 px-4 py-3">
<div class="min-w-0">
<p class="font-medium text-white">
<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-400" x-show="line.note" x-text="line.note"></p>
<p class="mt-0.5 text-xs text-emerald-300" x-show="line.discount_minor > 0"
x-text="'Discount ' + money(line.discount_minor)"></p>
</div>
<p class="shrink-0 font-semibold tabular-nums" x-text="money(line.line_total_minor)"></p>
</li>
</template>
</ul>
<div class="mt-4 space-y-1.5 border-t border-white/10 pt-4 text-sm">
<div class="flex justify-between text-slate-300">
<span>Subtotal</span>
<span class="tabular-nums" x-text="money(state.subtotal_minor)"></span>
</div>
<div class="flex justify-between text-emerald-300" 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-300" 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-300" x-show="state.tip?.selected_minor > 0">
<span>Tip</span>
<span class="tabular-nums" x-text="money(state.tip.selected_minor)"></span>
</div>
<div class="flex justify-between text-2xl font-bold tracking-tight">
<span>Total</span>
<span class="tabular-nums text-indigo-300" x-text="money(grandTotal())"></span>
</div>
</div>
{{-- Loyalty --}}
<div x-show="state.loyalty" x-cloak
class="mt-4 rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm">
<p class="font-semibold text-amber-200" x-text="state.loyalty?.label || 'Loyalty'"></p>
<div class="mt-1 flex flex-wrap gap-4 text-amber-100/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>
{{-- Right panel: payment / tip / signature --}}
<aside class="flex w-full flex-col justify-center gap-5 border-t border-white/10 bg-slate-900/60 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-300">Payment</p>
<p class="text-xl font-semibold" x-text="state.payment?.message || 'Please complete payment'"></p>
<p class="text-sm text-slate-400 capitalize" 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-white/15 bg-white/5 px-3 py-2 text-sm font-medium hover:bg-white/10"
x-text="opt"></button>
</template>
</div>
<div x-show="qrData()" class="flex flex-col items-center gap-3 rounded-2xl bg-white p-4 text-slate-900">
<img :src="qrImage(qrData())" alt="Payment QR" class="h-48 w-48">
<p class="text-center text-xs text-slate-600">Scan to pay with your phone</p>
</div>
</div>
{{-- Tip (restaurants) --}}
<div x-show="isPhase('tip')" class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-300">Add a tip</p>
<p class="text-xl font-semibold">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 ? 'bg-indigo-500 text-white ring-2 ring-indigo-300' : 'bg-white/5 hover:bg-white/10'"
class="rounded-2xl px-3 py-4 text-center transition">
<span class="block text-2xl font-bold" x-text="pct + '%'"></span>
<span class="mt-1 block text-xs text-slate-300" x-text="money(tipAmount(pct))"></span>
</button>
</template>
</div>
<button type="button" @click="selectTipPercent(0)"
class="w-full rounded-xl border border-white/15 px-3 py-2.5 text-sm text-slate-300 hover:bg-white/5">
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-0 bg-white/10 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-400">
<button type="button" @click="selectCustomTip()"
class="rounded-xl bg-indigo-500 px-4 py-2 text-sm font-semibold hover:bg-indigo-400">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-300">Signature</p>
<p class="text-lg font-semibold" x-text="state.signature?.prompt || 'Please sign below'"></p>
<div class="overflow-hidden rounded-2xl bg-white">
<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-white/15 px-3 py-2 text-sm hover:bg-white/5">Clear</button>
<button type="button" @click="submitSignature()"
class="flex-1 rounded-xl bg-indigo-500 px-3 py-2 text-sm font-semibold hover:bg-indigo-400">Done</button>
</div>
<p x-show="sigSaved" class="text-sm text-emerald-300">Signature saved thank you.</p>
</div>
{{-- Cart-only helper --}}
<div x-show="isPhase('cart')" class="text-center text-sm text-slate-400">
<p>Please review your order</p>
<p class="mt-1 text-xs">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-500/20 text-3xl text-emerald-300"></div>
<div>
<h1 class="text-3xl font-bold sm:text-4xl" x-text="state.receipt?.message || 'Thank you!'"></h1>
<p class="mt-2 text-slate-400" x-show="state.receipt?.reference || state.sale_reference">
Receipt <span class="text-white" x-text="state.receipt?.reference || state.sale_reference"></span>
</p>
<p class="mt-4 text-4xl font-bold tabular-nums text-indigo-300" x-text="money(state.receipt?.total_minor ?? state.total_minor)"></p>
</div>
<div x-show="state.receipt?.digital_url" class="rounded-2xl bg-white p-4">
<img :src="qrImage(state.receipt.digital_url)" alt="Digital receipt QR" class="h-40 w-40">
<p class="mt-2 text-xs text-slate-600">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-400">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-0 bg-white/10 px-3 py-2.5 text-sm text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-400">
<button type="button" @click="sendReceiptEmail()"
class="rounded-xl bg-indigo-500 px-4 py-2.5 text-sm font-semibold hover:bg-indigo-400">Send</button>
</div>
<p x-show="emailSent" class="mt-2 text-sm text-emerald-300">Request sent to the till.</p>
</div>
<p class="text-xs text-slate-500" 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,
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());
// Same-browser dual window: instant cart updates without waiting for SSE.
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 tip = Number(this.state.tip?.selected_minor) || 0;
return base + tip;
},
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;
await this.postAction({ type: 'receipt_email', email: this.receiptEmail });
this.emailSent = true;
},
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>
+170 -1
View File
@@ -3,6 +3,11 @@
'products' => $products,
'lookupUrl' => $barcodeLookupUrl,
'csrf' => csrf_token(),
'customerDisplayUrl' => $customerDisplayUrl,
'customerDisplayPushUrl' => $customerDisplayPushUrl,
'customerDisplayActionsUrl' => $customerDisplayActionsUrl,
'isRestaurant' => $isRestaurant,
'currency' => $location->currency,
]))">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -24,7 +29,12 @@
])>Restaurant</button>
</form>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<button type="button" @click="openCustomerDisplay()"
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
title="Open the customer-facing screen on a second monitor">
Customer screen
</button>
<button type="button" @click="tab = 'catalog'" :class="tab === 'catalog' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700 ring-1 ring-slate-200'"
class="rounded-xl px-4 py-2 text-sm font-medium">Catalog</button>
<button type="button" @click="tab = 'quick'" :class="tab === 'quick' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700 ring-1 ring-slate-200'"
@@ -111,11 +121,20 @@
<div id="line-fields"></div>
<div class="mt-5 flex flex-col gap-2">
<template x-if="isRestaurant">
<div class="grid grid-cols-2 gap-2">
<button type="button" @click="requestTip()" :disabled="cart.length === 0"
class="rounded-xl border border-slate-200 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50">Ask tip</button>
<button type="button" @click="requestSignature()" :disabled="cart.length === 0"
class="rounded-xl border border-slate-200 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50">Ask signature</button>
</div>
</template>
<button type="submit" name="payment_method" value="pay" :disabled="cart.length === 0"
class="btn-primary w-full disabled:opacity-50">Charge MoMo / card</button>
<button type="submit" name="payment_method" value="cash" :disabled="cart.length === 0"
class="w-full rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50">Record cash</button>
</div>
<p x-show="customerActionNote" x-cloak class="mt-3 text-xs text-indigo-700" x-text="customerActionNote"></p>
</form>
</div>
</div>
@@ -128,6 +147,11 @@
products: config.products || [],
lookupUrl: config.lookupUrl || '',
csrf: config.csrf || '',
customerDisplayUrl: config.customerDisplayUrl || '',
customerDisplayPushUrl: config.customerDisplayPushUrl || '',
customerDisplayActionsUrl: config.customerDisplayActionsUrl || '',
isRestaurant: !!config.isRestaurant,
currency: config.currency || 'GHS',
cart: [],
quickAmount: '',
quickLabel: 'Sale',
@@ -136,8 +160,19 @@
scanOk: '',
scanBuffer: '',
scanTimer: null,
pushTimer: null,
actionTimer: null,
customerActionNote: '',
customerWindow: null,
bc: null,
init() {
document.addEventListener('keydown', (e) => this.onGlobalKeydown(e));
this.$watch('cart', () => this.scheduleDisplayPush(), { deep: true });
try {
this.bc = new BroadcastChannel('pos-customer-display');
} catch (_) {}
this.actionTimer = setInterval(() => this.pollCustomerActions(), 3000);
this.scheduleDisplayPush();
},
onGlobalKeydown(e) {
if (this.shouldIgnoreScan(e)) return;
@@ -259,6 +294,140 @@
container.appendChild(input);
});
});
// Best-effort: show payment phase on customer screen before navigation.
this.pushDisplay({
phase: 'payment',
lines: this.cartLinesPayload(),
payment: {
method: e.submitter?.value || 'pay',
status: 'pending',
message: e.submitter?.value === 'cash'
? 'Cash payment at the register'
: 'Complete payment with card or MoMo',
options: e.submitter?.value === 'cash' ? ['Cash'] : ['Card / MoMo'],
},
});
},
openCustomerDisplay() {
if (!this.customerDisplayUrl) return;
this.customerWindow = window.open(
this.customerDisplayUrl,
'posCustomerDisplay',
'noopener,noreferrer'
);
this.scheduleDisplayPush();
},
cartLinesPayload() {
return this.cart.map((line) => ({
name: line.name,
quantity: line.quantity,
unit_price_minor: line.unit_price_minor,
discount_minor: line.discount_minor || 0,
note: line.note || null,
}));
},
scheduleDisplayPush() {
clearTimeout(this.pushTimer);
this.pushTimer = setTimeout(() => this.pushCartToDisplay(), 250);
},
async pushCartToDisplay() {
const lines = this.cartLinesPayload();
const payload = lines.length === 0
? { phase: 'idle' }
: {
phase: 'cart',
lines,
customer_name: document.getElementById('customer_name')?.value || null,
tip: this.isRestaurant ? {
enabled: true,
options_percent: [10, 15, 18, 20],
custom_allowed: true,
base_minor: this.cartTotal(),
} : undefined,
};
await this.pushDisplay(payload);
},
async requestTip() {
if (this.cart.length === 0) return;
await this.pushDisplay({
phase: 'tip',
lines: this.cartLinesPayload(),
tip: {
enabled: true,
options_percent: [10, 15, 18, 20],
custom_allowed: true,
base_minor: this.cartTotal(),
},
});
this.customerActionNote = 'Tip request shown on customer screen.';
},
async requestSignature() {
if (this.cart.length === 0) return;
await this.pushDisplay({
phase: 'signature',
lines: this.cartLinesPayload(),
signature: {
required: true,
prompt: 'Please sign to confirm your order',
},
});
this.customerActionNote = 'Signature pad shown on customer screen.';
},
async pushDisplay(body) {
if (!this.customerDisplayPushUrl) return;
try {
const res = await fetch(this.customerDisplayPushUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': this.csrf,
},
body: JSON.stringify(body),
});
// Mirror to same-browser customer window via BroadcastChannel when possible.
if (this.bc && res.ok) {
// Customer screen will also pick up via SSE; BC is a fast path after its next poll.
}
} catch (_) {}
},
async pollCustomerActions() {
if (!this.customerDisplayActionsUrl) return;
try {
const res = await fetch(this.customerDisplayActionsUrl + '?clear=1', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!res.ok) return;
const data = await res.json();
const action = data.action;
if (!action?.type) return;
if (action.type === 'tip') {
const tip = action.tip_minor != null
? (this.currency + ' ' + (Number(action.tip_minor) / 100).toFixed(2))
: ((action.tip_percent ?? 0) + '%');
this.customerActionNote = 'Customer selected tip: ' + tip;
} else if (action.type === 'signature') {
this.customerActionNote = 'Customer signature captured.';
} else if (action.type === 'receipt_email' && action.email) {
this.customerActionNote = 'Customer requested receipt email: ' + action.email;
const emailInput = document.getElementById('customer_email');
if (emailInput && !emailInput.value) emailInput.value = action.email;
} else if (action.type === 'payment_option') {
this.customerActionNote = 'Customer chose payment: ' + (action.payment_option || '');
}
} catch (_) {}
},
};
}