Files
ladill-pos/resources/views/pos/customer-display.blade.php
T
isaacclad e5b4f3d654
Deploy Ladill POS / deploy (push) Successful in 58s
Show promo image again on the idle customer display.
Restore the configured promo image above the welcome copy when the
register cart is empty so store branding still surfaces to customers.
2026-07-15 21:15:13 +00:00

469 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 --}}
<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">
<div class="flex items-center justify-center overflow-hidden rounded-3xl bg-white px-6 py-5 shadow-sm ring-1 ring-slate-200/80 sm:px-8 sm:py-6">
<img :src="state.promo.image_url"
alt=""
class="h-28 w-auto max-h-40 max-w-[min(90vw,360px)] object-contain sm:h-36 sm:max-h-44 sm:max-w-[420px]">
</div>
</template>
<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>
</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>