Add dual-screen customer display for the register.
Deploy Ladill POS / deploy (push) Successful in 56s
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:
@@ -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>
|
||||
@@ -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 (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user