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.
565 lines
31 KiB
PHP
565 lines
31 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,
|
||
'loyaltyUrl' => $loyaltyUrl,
|
||
'loyaltyEnabled' => $loyaltyEnabled,
|
||
'crmCustomers' => array_values($crmCustomers),
|
||
'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 space-y-1.5">
|
||
<div class="flex justify-between text-sm" x-show="loyaltyDiscountMinor > 0">
|
||
<span class="text-emerald-600">Loyalty discount</span>
|
||
<span class="font-medium text-emerald-700" x-text="'−' + formatMoney(loyaltyDiscountMinor)"></span>
|
||
</div>
|
||
<div class="flex justify-between text-sm" x-show="tipMinor > 0">
|
||
<span class="text-slate-500">Tip</span>
|
||
<span class="font-medium text-slate-800" x-text="formatMoney(tipMinor)"></span>
|
||
</div>
|
||
<div class="flex justify-between text-sm">
|
||
<span class="text-slate-500">Total</span>
|
||
<span class="font-semibold text-slate-900" x-text="formatMoney(payableTotal())"></span>
|
||
</div>
|
||
<input type="hidden" name="tip_minor" :value="tipMinor || 0">
|
||
</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 x-show="loyaltyEnabled && crmCustomerId" x-cloak
|
||
class="rounded-xl border border-amber-200 bg-amber-50/70 p-3 space-y-2">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-amber-800" x-text="loyaltyLabel"></p>
|
||
<p class="text-sm font-semibold text-amber-900">
|
||
<span x-text="loyaltyBalance"></span> pts
|
||
</p>
|
||
</div>
|
||
<p class="text-xs text-amber-800/80" x-show="loyaltyEarnPreview > 0">
|
||
This sale earns ~<strong x-text="loyaltyEarnPreview"></strong> pts
|
||
</p>
|
||
<div>
|
||
<label class="text-xs font-medium text-amber-900">Redeem points</label>
|
||
<div class="mt-1 flex gap-2">
|
||
<input type="number" min="0" :max="loyaltyMaxRedeem" x-model.number="loyaltyRedeemPoints"
|
||
@input="scheduleLoyaltyQuote()"
|
||
name="loyalty_redeem_points"
|
||
class="block w-full rounded-lg border-amber-200 text-sm shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||
placeholder="0">
|
||
<button type="button" @click="redeemMax()"
|
||
class="shrink-0 rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-900 hover:bg-amber-50">Max</button>
|
||
</div>
|
||
<p class="mt-1 text-[11px] text-amber-800/70" x-show="loyaltyMaxRedeem > 0">
|
||
Max <span x-text="loyaltyMaxRedeem"></span> pts
|
||
(<span x-text="formatMoney(loyaltyDiscountMinor)"></span> off)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</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 || '',
|
||
loyaltyUrl: config.loyaltyUrl || '',
|
||
loyaltyEnabled: !!config.loyaltyEnabled,
|
||
crmCustomers: config.crmCustomers || [],
|
||
crmCustomerId: '',
|
||
loyaltyLabel: 'Rewards',
|
||
loyaltyBalance: 0,
|
||
loyaltyEarnPreview: 0,
|
||
loyaltyRedeemPoints: 0,
|
||
loyaltyMaxRedeem: 0,
|
||
loyaltyDiscountMinor: 0,
|
||
loyaltyQuoteTimer: null,
|
||
tipMinor: 0,
|
||
tipPercent: null,
|
||
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();
|
||
this.scheduleLoyaltyQuote();
|
||
}, { deep: true });
|
||
try {
|
||
this.bc = new BroadcastChannel('pos-customer-display');
|
||
} catch (_) {}
|
||
this.actionTimer = setInterval(() => this.pollCustomerActions(), 3000);
|
||
this.scheduleDisplayPush();
|
||
},
|
||
onCustomerSelected() {
|
||
const c = this.crmCustomers.find((x) => String(x.id) === String(this.crmCustomerId));
|
||
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v || ''; };
|
||
if (c) {
|
||
set('customer_name', c.name);
|
||
set('customer_email', c.email || '');
|
||
set('customer_phone', c.phone || '');
|
||
}
|
||
this.loyaltyRedeemPoints = 0;
|
||
this.loyaltyDiscountMinor = 0;
|
||
this.scheduleLoyaltyQuote();
|
||
this.scheduleDisplayPush();
|
||
},
|
||
redeemMax() {
|
||
this.loyaltyRedeemPoints = this.loyaltyMaxRedeem || 0;
|
||
this.scheduleLoyaltyQuote();
|
||
},
|
||
scheduleLoyaltyQuote() {
|
||
clearTimeout(this.loyaltyQuoteTimer);
|
||
this.loyaltyQuoteTimer = setTimeout(() => this.fetchLoyaltyQuote(), 250);
|
||
},
|
||
async fetchLoyaltyQuote() {
|
||
if (!this.loyaltyEnabled || !this.crmCustomerId || !this.loyaltyUrl) {
|
||
this.loyaltyBalance = 0;
|
||
this.loyaltyEarnPreview = 0;
|
||
this.loyaltyMaxRedeem = 0;
|
||
this.loyaltyDiscountMinor = 0;
|
||
return;
|
||
}
|
||
try {
|
||
const params = new URLSearchParams({
|
||
crm_customer_id: this.crmCustomerId,
|
||
subtotal_minor: String(this.cartTotal()),
|
||
redeem_points: String(this.loyaltyRedeemPoints || 0),
|
||
});
|
||
const res = await fetch(this.loyaltyUrl + '?' + params, {
|
||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!data.enabled) {
|
||
this.loyaltyEnabled = false;
|
||
return;
|
||
}
|
||
const snap = data.snapshot || {};
|
||
const quote = data.quote || {};
|
||
this.loyaltyLabel = snap.label || snap.program?.name || 'Rewards';
|
||
this.loyaltyBalance = snap.points_balance || 0;
|
||
this.loyaltyEarnPreview = quote.points_earn ?? snap.points_earn ?? 0;
|
||
this.loyaltyMaxRedeem = quote.max_points ?? snap.max_redeem_points ?? 0;
|
||
this.loyaltyDiscountMinor = quote.discount_minor || 0;
|
||
if (this.loyaltyRedeemPoints > this.loyaltyMaxRedeem) {
|
||
this.loyaltyRedeemPoints = this.loyaltyMaxRedeem;
|
||
}
|
||
} catch (_) {}
|
||
},
|
||
payableTotal() {
|
||
return Math.max(0, this.cartTotal() - (this.loyaltyDiscountMinor || 0)) + (this.tipMinor || 0);
|
||
},
|
||
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,
|
||
discount_minor: this.loyaltyDiscountMinor || 0,
|
||
customer_name: document.getElementById('customer_name')?.value || null,
|
||
loyalty: (this.loyaltyEnabled && this.crmCustomerId) ? {
|
||
label: this.loyaltyLabel,
|
||
points_balance: this.loyaltyBalance,
|
||
points_earn: this.loyaltyEarnPreview,
|
||
points_redeem: this.loyaltyRedeemPoints || 0,
|
||
} : null,
|
||
tip: this.isRestaurant ? {
|
||
enabled: true,
|
||
options_percent: [10, 15, 18, 20],
|
||
custom_allowed: true,
|
||
base_minor: this.payableTotal(),
|
||
} : 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') {
|
||
this.tipMinor = Number(action.tip_minor) || 0;
|
||
this.tipPercent = action.tip_percent ?? null;
|
||
const tip = this.tipMinor
|
||
? (this.currency + ' ' + (this.tipMinor / 100).toFixed(2))
|
||
: ((action.tip_percent ?? 0) + '%');
|
||
this.customerActionNote = this.tipMinor
|
||
? ('Tip applied: ' + tip + ' — will be added at charge.')
|
||
: 'Customer declined tip.';
|
||
this.scheduleDisplayPush();
|
||
} else if (action.type === 'signature') {
|
||
this.customerActionNote = 'Customer signature captured.';
|
||
} else if (action.type === 'receipt_email' && action.email) {
|
||
const emailInput = document.getElementById('customer_email');
|
||
if (emailInput && !emailInput.value) emailInput.value = action.email;
|
||
this.customerActionNote = action.email_sent
|
||
? ('Receipt emailed to ' + action.email)
|
||
: (action.email_message || ('Receipt email: ' + action.email));
|
||
} else if (action.type === 'payment_option') {
|
||
this.customerActionNote = 'Customer chose payment: ' + (action.payment_option || '');
|
||
}
|
||
} catch (_) {}
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
</x-app-layout>
|