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.
436 lines
24 KiB
PHP
436 lines
24 KiB
PHP
<x-app-layout title="Register">
|
||
<div class="mx-auto max-w-6xl" x-data="posRegister(@js([
|
||
'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>
|
||
<h1 class="text-lg font-semibold text-slate-900">Register</h1>
|
||
<p class="mt-1 text-sm text-slate-500">{{ $location->name }} · {{ $location->currency }}</p>
|
||
<form method="post" action="{{ route('pos.mode.set') }}" class="mt-2 inline-flex rounded-xl bg-slate-100 p-0.5">
|
||
@csrf
|
||
<button name="style" value="retail"
|
||
@class([
|
||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||
'bg-white text-slate-900 shadow-sm' => ! $location->isRestaurant(),
|
||
'text-slate-500 hover:text-slate-700' => $location->isRestaurant(),
|
||
])>Retail</button>
|
||
<button name="style" value="restaurant"
|
||
@class([
|
||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||
'bg-white text-slate-900 shadow-sm' => $location->isRestaurant(),
|
||
'text-slate-500 hover:text-slate-700' => ! $location->isRestaurant(),
|
||
])>Restaurant</button>
|
||
</form>
|
||
</div>
|
||
<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'"
|
||
class="rounded-xl px-4 py-2 text-sm font-medium">Quick amount</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-6 grid gap-6 lg:grid-cols-5">
|
||
<div class="lg:col-span-3 space-y-4">
|
||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||
<label for="barcode-scan" class="text-sm font-medium text-slate-700">Barcode scanner</label>
|
||
<p class="mt-0.5 text-xs text-slate-400">Scan with a USB scanner or type a SKU/barcode and press Enter.</p>
|
||
<div class="mt-2 flex gap-2">
|
||
<input type="text" id="barcode-scan" x-ref="scanInput" x-model="scanCode"
|
||
@keydown.enter.prevent="submitScan()"
|
||
placeholder="Scan barcode…" autocomplete="off"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<button type="button" @click="submitScan()"
|
||
class="shrink-0 rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">Add</button>
|
||
</div>
|
||
<p x-show="scanError" x-cloak class="mt-2 text-xs text-red-600" x-text="scanError"></p>
|
||
<p x-show="scanOk" x-cloak class="mt-2 text-xs text-green-700" x-text="scanOk"></p>
|
||
</div>
|
||
|
||
<div x-show="tab === 'catalog'" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||
<template x-for="product in products" :key="product.id">
|
||
<button type="button" @click="addProduct(product)"
|
||
class="rounded-2xl border border-slate-200 bg-white p-4 text-left hover:border-indigo-200 hover:shadow-sm">
|
||
<p class="font-medium text-slate-900" x-text="product.name"></p>
|
||
<p class="mt-0.5 text-xs text-slate-400" x-show="product.sku" x-text="product.sku"></p>
|
||
<p class="mt-1 text-sm text-indigo-600" x-text="formatMoney(product.price_minor)"></p>
|
||
</button>
|
||
</template>
|
||
@if (empty($products))
|
||
<p class="col-span-full text-sm text-slate-500">No products yet. <a href="{{ route('pos.products.create') }}" class="text-indigo-600">Add products</a> or use Quick amount.</p>
|
||
@endif
|
||
</div>
|
||
|
||
<div x-show="tab === 'quick'" x-cloak class="rounded-2xl border border-slate-200 bg-white p-6">
|
||
<label class="text-sm font-medium text-slate-700">Amount ({{ $location->currency }})</label>
|
||
<input type="number" x-model="quickAmount" min="0.01" step="0.01" placeholder="0.00"
|
||
class="mt-2 block w-full rounded-xl border-slate-300 text-lg shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<label class="mt-4 block text-sm font-medium text-slate-700">Description</label>
|
||
<input type="text" x-model="quickLabel" placeholder="Sale"
|
||
class="mt-2 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<button type="button" @click="addQuick()" class="mt-4 rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">Add to cart</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lg:col-span-2">
|
||
<form method="POST" action="{{ route('pos.register.charge') }}" @submit="prepareSubmit" class="rounded-2xl border border-slate-200 bg-white p-5">
|
||
@csrf
|
||
<h2 class="font-semibold text-slate-900">Cart</h2>
|
||
<ul class="mt-4 space-y-2 max-h-64 overflow-y-auto">
|
||
<template x-for="(line, index) in cart" :key="index">
|
||
<li class="flex items-start justify-between gap-2 rounded-xl bg-slate-50 px-3 py-2 text-sm">
|
||
<div>
|
||
<p class="font-medium text-slate-800" x-text="line.name"></p>
|
||
<p class="text-xs text-slate-500"><span x-text="line.quantity"></span> × <span x-text="formatMoney(line.unit_price_minor)"></span></p>
|
||
</div>
|
||
<button type="button" @click="removeLine(index)" class="text-slate-400 hover:text-red-600">×</button>
|
||
</li>
|
||
</template>
|
||
<li x-show="cart.length === 0" class="text-sm text-slate-500">Cart is empty.</li>
|
||
</ul>
|
||
|
||
<div class="mt-4 border-t border-slate-100 pt-4">
|
||
<div class="flex justify-between text-sm">
|
||
<span class="text-slate-500">Total</span>
|
||
<span class="font-semibold text-slate-900" x-text="formatMoney(cartTotal())"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 space-y-3">
|
||
@include('pos.partials.customer-picker', ['customers' => $crmCustomers])
|
||
<input type="text" id="customer_name" name="customer_name" placeholder="Customer name (optional)"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<input type="email" id="customer_email" name="customer_email" placeholder="Email (optional)"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<input type="tel" id="customer_phone" name="customer_phone" placeholder="Phone (optional)"
|
||
class="block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
</div>
|
||
|
||
<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>
|
||
</div>
|
||
|
||
<script>
|
||
function posRegister(config) {
|
||
return {
|
||
tab: 'catalog',
|
||
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',
|
||
scanCode: '',
|
||
scanError: '',
|
||
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;
|
||
if (e.key === 'Enter') {
|
||
const code = (this.scanBuffer || '').trim();
|
||
this.scanBuffer = '';
|
||
if (code.length >= 3) {
|
||
e.preventDefault();
|
||
this.scanCode = code;
|
||
this.submitScan();
|
||
}
|
||
return;
|
||
}
|
||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||
this.scanBuffer += e.key;
|
||
clearTimeout(this.scanTimer);
|
||
this.scanTimer = setTimeout(() => { this.scanBuffer = ''; }, 120);
|
||
}
|
||
},
|
||
shouldIgnoreScan(e) {
|
||
const tag = (e.target?.tagName || '').toLowerCase();
|
||
if (tag === 'textarea') return true;
|
||
if (tag === 'input' && e.target?.id !== 'barcode-scan') return true;
|
||
if (e.target?.isContentEditable) return true;
|
||
return false;
|
||
},
|
||
addProduct(product) {
|
||
const existing = this.cart.find(l => l.product_id === product.id);
|
||
if (existing) {
|
||
existing.quantity += 1;
|
||
} else {
|
||
this.cart.push({
|
||
product_id: product.id,
|
||
name: product.name,
|
||
unit_price_minor: product.price_minor,
|
||
quantity: 1,
|
||
});
|
||
}
|
||
this.scanError = '';
|
||
this.scanOk = product.name + ' added';
|
||
setTimeout(() => { this.scanOk = ''; }, 1500);
|
||
},
|
||
async submitScan() {
|
||
const code = (this.scanCode || '').trim();
|
||
this.scanError = '';
|
||
this.scanOk = '';
|
||
if (!code) return;
|
||
|
||
const local = this.products.find(p => p.sku && String(p.sku).toLowerCase() === code.toLowerCase());
|
||
if (local) {
|
||
this.addProduct(local);
|
||
this.scanCode = '';
|
||
return;
|
||
}
|
||
|
||
if (!this.lookupUrl) {
|
||
this.scanError = 'No product found for that barcode.';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(this.lookupUrl + '?' + new URLSearchParams({ code }), {
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': this.csrf,
|
||
},
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
this.scanError = data.message || 'No product found for that barcode.';
|
||
return;
|
||
}
|
||
this.addProduct(data.product);
|
||
this.scanCode = '';
|
||
} catch (err) {
|
||
this.scanError = 'Barcode lookup failed. Try again.';
|
||
}
|
||
},
|
||
addQuick() {
|
||
const amount = Math.round(parseFloat(this.quickAmount || 0) * 100);
|
||
if (amount <= 0) return;
|
||
this.cart.push({
|
||
product_id: null,
|
||
name: this.quickLabel || 'Sale',
|
||
unit_price_minor: amount,
|
||
quantity: 1,
|
||
});
|
||
this.quickAmount = '';
|
||
},
|
||
removeLine(index) {
|
||
this.cart.splice(index, 1);
|
||
},
|
||
cartTotal() {
|
||
return this.cart.reduce((sum, l) => sum + l.unit_price_minor * l.quantity, 0);
|
||
},
|
||
formatMoney(minor) {
|
||
return '{{ $location->currency }} ' + (minor / 100).toFixed(2);
|
||
},
|
||
prepareSubmit(e) {
|
||
if (this.cart.length === 0) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
const container = document.getElementById('line-fields');
|
||
container.innerHTML = '';
|
||
this.cart.forEach((line, i) => {
|
||
const fields = [
|
||
['product_id', line.product_id ?? ''],
|
||
['name', line.name],
|
||
['unit_price_minor', line.unit_price_minor],
|
||
['quantity', line.quantity],
|
||
];
|
||
fields.forEach(([key, val]) => {
|
||
const input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = `lines[${i}][${key}]`;
|
||
input.value = val;
|
||
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 (_) {}
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
</x-app-layout>
|