Deploy Ladill POS / deploy (push) Successful in 31s
Send real receipt emails from the customer display and sale page, store branch promo content for the idle screen, and fold selected tips into totals.
448 lines
24 KiB
PHP
448 lines
24 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>
|
||
<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">Receipt emailed — check your inbox.</p>
|
||
<p x-show="emailError" class="mt-2 text-sm text-rose-300" x-text="emailError"></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,
|
||
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());
|
||
// 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;
|
||
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>
|