Deploy Ladill POS / deploy (push) Successful in 1m0s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
650 lines
24 KiB
JavaScript
650 lines
24 KiB
JavaScript
import './bootstrap';
|
|
|
|
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
|
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
|
|
|
registerLadillModalHelpers();
|
|
registerLadillSearchShortcut();
|
|
|
|
|
|
import Alpine from 'alpinejs';
|
|
import { registerLadillClipboard } from './ladill-clipboard';
|
|
import collapse from '@alpinejs/collapse';
|
|
import QRCodeStyling from 'qr-code-styling';
|
|
window.QRCodeStyling = QRCodeStyling;
|
|
import qrcode from 'qrcode-generator';
|
|
window.qrcode = qrcode;
|
|
|
|
Alpine.plugin(collapse);
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.store('paymentCheckout', {
|
|
isOpen: false,
|
|
url: '',
|
|
accessCode: '',
|
|
open(url, accessCode = '') {
|
|
this.url = url || '';
|
|
this.accessCode = accessCode || '';
|
|
this.isOpen = this.url !== '' || this.accessCode !== '';
|
|
},
|
|
close() {
|
|
this.isOpen = false;
|
|
this.url = '';
|
|
this.accessCode = '';
|
|
window.LadillPayCheckout?.cancel?.();
|
|
},
|
|
});
|
|
});
|
|
|
|
registerLadillClipboard(Alpine);
|
|
|
|
Alpine.data('notificationDropdown', (config = {}) => ({
|
|
open: false,
|
|
loading: false,
|
|
notifications: [],
|
|
unreadCount: 0,
|
|
unreadUrl: config.unreadUrl || '/notifications/unread',
|
|
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
|
|
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
|
|
indexUrl: config.indexUrl || '/notifications',
|
|
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
|
|
init() {
|
|
this.fetchUnread();
|
|
setInterval(() => this.fetchUnread(), 60000);
|
|
},
|
|
|
|
async fetchUnread() {
|
|
try {
|
|
const res = await fetch(this.unreadUrl, {
|
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await res.json();
|
|
this.notifications = data.notifications || [];
|
|
this.unreadCount = data.unread_count || 0;
|
|
} catch (e) {
|
|
console.error('Failed to fetch notifications', e);
|
|
}
|
|
},
|
|
|
|
toggle() {
|
|
this.open = !this.open;
|
|
if (this.open) {
|
|
this.loading = true;
|
|
this.fetchUnread().finally(() => { this.loading = false; });
|
|
}
|
|
},
|
|
|
|
async markRead(id) {
|
|
const url = this.markReadUrl.replace('__ID__', id);
|
|
try {
|
|
await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': this.csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
this.notifications = this.notifications.filter(n => n.id !== id);
|
|
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
|
} catch (e) {
|
|
console.error('Failed to mark notification as read', e);
|
|
}
|
|
},
|
|
|
|
async markAllRead() {
|
|
try {
|
|
await fetch(this.markAllReadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': this.csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
this.notifications = [];
|
|
this.unreadCount = 0;
|
|
} catch (e) {
|
|
console.error('Failed to mark all notifications as read', e);
|
|
}
|
|
},
|
|
|
|
getIconBg(icon) {
|
|
const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' };
|
|
return map[icon] || 'bg-slate-100';
|
|
},
|
|
|
|
getIconColor(icon) {
|
|
const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' };
|
|
return map[icon] || 'text-slate-500';
|
|
},
|
|
}));
|
|
|
|
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
|
|
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
|
|
Alpine.data('afia', (config = {}) => ({
|
|
open: false,
|
|
input: '',
|
|
loading: false,
|
|
messages: [
|
|
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
|
|
],
|
|
suggestions: config.suggestions || [],
|
|
init() {
|
|
window.addEventListener('afia-open', () => {
|
|
this.open = true;
|
|
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
|
|
});
|
|
},
|
|
close() { this.open = false; },
|
|
useSuggestion(s) { this.input = s; this.send(); },
|
|
scrollDown() {
|
|
this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; });
|
|
},
|
|
async send() {
|
|
const text = this.input.trim();
|
|
if (!text || this.loading) return;
|
|
const history = this.messages.map((m) => ({ role: m.role, text: m.text }));
|
|
this.messages.push({ role: 'user', text });
|
|
this.input = '';
|
|
this.loading = true;
|
|
this.scrollDown();
|
|
try {
|
|
const res = await fetch(config.chatUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' },
|
|
body: JSON.stringify({ message: text, history }),
|
|
});
|
|
const data = await res.json();
|
|
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
|
|
} catch (e) {
|
|
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
|
|
}
|
|
this.loading = false;
|
|
this.scrollDown();
|
|
},
|
|
}));
|
|
|
|
function mobileKeyboardBottomOffset() {
|
|
const viewport = window.visualViewport;
|
|
if (!viewport) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
|
|
}
|
|
|
|
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
|
amount: config.amount ?? '',
|
|
loading: false,
|
|
errorMsg: config.errorMsg ?? '',
|
|
showSheet: false,
|
|
checkoutUrl: '',
|
|
accessCode: '',
|
|
returnUrl: '',
|
|
paymentSheetStyle: '',
|
|
sheetBleedStyle: '',
|
|
|
|
init() {
|
|
this._syncSheet = () => {
|
|
if (window.innerWidth >= 768) {
|
|
this.paymentSheetStyle = '';
|
|
this.sheetBleedStyle = '';
|
|
return;
|
|
}
|
|
|
|
const offset = mobileKeyboardBottomOffset();
|
|
const safePad = offset > 0 ? '1.25rem' : 'max(1.25rem, env(safe-area-inset-bottom))';
|
|
this.paymentSheetStyle = `bottom: ${offset}px; padding-bottom: ${safePad};`;
|
|
this.sheetBleedStyle = `bottom: ${offset}px;`;
|
|
};
|
|
|
|
this._onViewportChange = () => this._syncSheet();
|
|
this._onFocusChange = () => {
|
|
requestAnimationFrame(this._syncSheet);
|
|
setTimeout(this._syncSheet, 150);
|
|
setTimeout(this._syncSheet, 350);
|
|
};
|
|
|
|
window.visualViewport?.addEventListener('resize', this._onViewportChange);
|
|
window.visualViewport?.addEventListener('scroll', this._onViewportChange);
|
|
document.addEventListener('focusin', this._onFocusChange);
|
|
document.addEventListener('focusout', this._onFocusChange);
|
|
|
|
if (window.innerWidth < 768) {
|
|
document.documentElement.classList.add('mini-payment-page');
|
|
}
|
|
|
|
this._syncSheet();
|
|
},
|
|
|
|
destroy() {
|
|
window.visualViewport?.removeEventListener('resize', this._onViewportChange);
|
|
window.visualViewport?.removeEventListener('scroll', this._onViewportChange);
|
|
document.removeEventListener('focusin', this._onFocusChange);
|
|
document.removeEventListener('focusout', this._onFocusChange);
|
|
document.documentElement.classList.remove('mini-payment-page');
|
|
},
|
|
|
|
async submitPay() {
|
|
const value = parseFloat(this.amount);
|
|
if (!value || value <= 0) {
|
|
this.errorMsg = 'Enter an amount greater than zero.';
|
|
return;
|
|
}
|
|
|
|
this.errorMsg = '';
|
|
this.loading = true; window.LadillPayCheckout?.prepare?.();
|
|
|
|
try {
|
|
const res = await fetch(config.payUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': config.csrf,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({ amount: value }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || data.error || data.message) {
|
|
this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.';
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
if (!data.checkout_url) {
|
|
this.errorMsg = 'Could not start payment. Please try again.';
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
this.checkoutUrl = data.checkout_url || data.authorization_url || '';
|
|
this.accessCode = data.access_code || '';
|
|
this.returnUrl = data.callback_url || this.returnUrl || '';
|
|
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
|
|
} catch (e) {
|
|
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
|
|
this.loading = false;
|
|
}
|
|
},
|
|
}));
|
|
|
|
Alpine.data('topbarSearch', (config = {}) => ({
|
|
query: config.initialQuery || '',
|
|
results: Array.isArray(config.initialResults) ? config.initialResults : [],
|
|
open: !!config.openOnInit,
|
|
loading: false,
|
|
active: 0,
|
|
_abort: null,
|
|
searchUrl: config.searchUrl || '/search',
|
|
|
|
init() {
|
|
if (config.autoFocus) {
|
|
this.$nextTick(() => this.$refs.input?.focus());
|
|
}
|
|
},
|
|
|
|
onFocus() {
|
|
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
|
|
},
|
|
|
|
async search() {
|
|
const q = this.query.trim();
|
|
if (q.length < 2) { this.results = []; this.open = false; return; }
|
|
|
|
this.loading = true;
|
|
this.open = true;
|
|
this.active = 0;
|
|
|
|
if (this._abort) this._abort.abort();
|
|
this._abort = new AbortController();
|
|
|
|
try {
|
|
const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, {
|
|
signal: this._abort.signal,
|
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await res.json();
|
|
this.results = data.results || [];
|
|
} catch (e) {
|
|
if (e.name !== 'AbortError') this.results = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
moveDown() { if (this.active < this.results.length - 1) this.active++; },
|
|
moveUp() { if (this.active > 0) this.active--; },
|
|
go() {
|
|
if (this.results[this.active]) window.location.href = this.results[this.active].url;
|
|
},
|
|
}));
|
|
|
|
|
|
// Wallet balance peek for the avatar dropdown.
|
|
Alpine.data('walletWidget', (config = {}) => ({
|
|
display: '…',
|
|
async load() {
|
|
if (! config.url) {
|
|
this.display = 'View wallet';
|
|
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
|
|
const data = await res.json();
|
|
this.display = data.available ? data.formatted : 'View wallet';
|
|
} catch (e) {
|
|
this.display = 'View wallet';
|
|
}
|
|
},
|
|
}));
|
|
|
|
// Restaurant ticket (open tab) — persists every change to the server and
|
|
// re-renders from the authoritative response. config: { base, lines, products, totalMinor, currency }.
|
|
Alpine.data('posTicket', (config = {}) => ({
|
|
lines: config.lines || [],
|
|
products: config.products || [],
|
|
totalMinor: config.totalMinor || 0,
|
|
currency: config.currency || 'GHS',
|
|
base: config.base || '',
|
|
csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
search: '',
|
|
busy: false,
|
|
flash: '',
|
|
|
|
activeCategory: '',
|
|
picker: { open: false, product: null, groups: [], notes: '', course: '' },
|
|
courseLabels: config.courseLabels || {},
|
|
paidMinor: config.paidMinor || 0,
|
|
settleAmount: '',
|
|
|
|
init() { this.settleAmount = (this.balanceMinor / 100).toFixed(2); },
|
|
get balanceMinor() { return Math.max(0, this.totalMinor - this.paidMinor); },
|
|
splitAmount(n) { this.settleAmount = (this.balanceMinor / 100 / n).toFixed(2); },
|
|
fullAmount() { this.settleAmount = (this.balanceMinor / 100).toFixed(2); },
|
|
|
|
money(minor) { return this.currency + ' ' + ((minor || 0) / 100).toFixed(2); },
|
|
get hasNew() { return this.lines.some((l) => ! l.fired); },
|
|
get categories() {
|
|
const set = [];
|
|
this.products.forEach((p) => { if (p.category && ! set.includes(p.category)) set.push(p.category); });
|
|
return set;
|
|
},
|
|
get filteredProducts() {
|
|
const q = this.search.trim().toLowerCase();
|
|
return this.products.filter((p) =>
|
|
(! this.activeCategory || p.category === this.activeCategory)
|
|
&& (! q || p.name.toLowerCase().includes(q)));
|
|
},
|
|
get newCourses() {
|
|
const s = [];
|
|
this.lines.forEach((l) => { if (! l.fired) { const c = l.course || ''; if (! s.includes(c)) s.push(c); } });
|
|
return s;
|
|
},
|
|
courseLabel(c) { return this.courseLabels[c] || 'No course'; },
|
|
|
|
// Modifier picker
|
|
tap(p) {
|
|
if (p.modifier_groups && p.modifier_groups.length) this.openPicker(p);
|
|
else this.addProduct(p, {});
|
|
},
|
|
openPicker(p) {
|
|
this.flash = '';
|
|
this.picker = {
|
|
open: true, product: p, notes: '', course: '',
|
|
groups: p.modifier_groups.map((g) => ({ ...g, selected: [] })),
|
|
};
|
|
},
|
|
closePicker() { this.picker.open = false; },
|
|
isSelected(g, m) { return g.selected.includes(m.id); },
|
|
toggleModifier(g, m) {
|
|
const i = g.selected.indexOf(m.id);
|
|
if (i >= 0) { g.selected.splice(i, 1); return; }
|
|
if (g.max === 1) { g.selected = [m.id]; return; }
|
|
if (g.max && g.selected.length >= g.max) return;
|
|
g.selected.push(m.id);
|
|
},
|
|
get pickerPrice() {
|
|
if (! this.picker.product) return 0;
|
|
let total = this.picker.product.price_minor;
|
|
this.picker.groups.forEach((g) => g.modifiers.forEach((m) => { if (g.selected.includes(m.id)) total += m.price_delta_minor; }));
|
|
return total;
|
|
},
|
|
get pickerValid() { return this.picker.groups.every((g) => g.selected.length >= (g.min || 0)); },
|
|
confirmPicker() {
|
|
if (! this.pickerValid) { this.flash = 'Choose the required options.'; return; }
|
|
const ids = this.picker.groups.flatMap((g) => g.selected);
|
|
this.addProduct(this.picker.product, { modifier_ids: ids, notes: this.picker.notes, course: this.picker.course });
|
|
this.closePicker();
|
|
},
|
|
|
|
async req(method, url, body) {
|
|
if (this.busy) return null;
|
|
this.busy = true;
|
|
this.flash = '';
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf, Accept: 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (! res.ok) {
|
|
const e = await res.json().catch(() => ({}));
|
|
this.flash = e.message || 'Something went wrong.';
|
|
return null;
|
|
}
|
|
return await res.json();
|
|
} catch (e) {
|
|
this.flash = 'Network error — try again.';
|
|
return null;
|
|
} finally {
|
|
this.busy = false;
|
|
}
|
|
},
|
|
apply(data) { if (data) { this.lines = data.lines; this.totalMinor = data.total_minor; } },
|
|
|
|
async addProduct(p, opts = {}) {
|
|
this.apply(await this.req('POST', this.base + '/lines', {
|
|
product_id: p.id,
|
|
quantity: 1,
|
|
modifier_ids: opts.modifier_ids || [],
|
|
notes: opts.notes || null,
|
|
course: opts.course || null,
|
|
}));
|
|
},
|
|
async setQty(line, qty) { this.apply(await this.req('PATCH', this.base + '/lines/' + line.id, { quantity: qty })); },
|
|
inc(line) { this.setQty(line, line.quantity + 1); },
|
|
dec(line) { this.setQty(line, line.quantity - 1); },
|
|
async remove(line) { this.apply(await this.req('DELETE', this.base + '/lines/' + line.id)); },
|
|
async send(course = null) {
|
|
const data = await this.req('POST', this.base + '/send', { course });
|
|
this.apply(data);
|
|
if (data) this.flash = data.fired > 0 ? ('Sent ' + data.fired + ' item(s) to the kitchen.') : 'Nothing new to send.';
|
|
},
|
|
}));
|
|
|
|
// Kitchen Display (KDS) — polls the active-ticket feed and bumps line states.
|
|
// config: { feedUrl, bumpUrl } where bumpUrl contains __ID__.
|
|
Alpine.data('posKitchen', (config = {}) => ({
|
|
tickets: [],
|
|
feedUrl: config.feedUrl || '/kitchen/feed',
|
|
streamUrl: config.streamUrl || '',
|
|
bumpUrl: config.bumpUrl || '/kitchen/lines/__ID__/bump',
|
|
csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
now: Date.now(),
|
|
loaded: false,
|
|
activeStation: '',
|
|
es: null,
|
|
|
|
init() {
|
|
this.load(); // fast initial paint
|
|
this.connect(); // realtime stream (falls back to polling)
|
|
setInterval(() => { this.now = Date.now(); }, 1000);
|
|
},
|
|
connect() {
|
|
if (! this.streamUrl || typeof window.EventSource === 'undefined') {
|
|
setInterval(() => this.load(), 4000); // fallback polling
|
|
return;
|
|
}
|
|
this.es = new EventSource(this.streamUrl); // auto-reconnects on close/error
|
|
this.es.onmessage = (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
this.tickets = data.tickets || [];
|
|
this.loaded = true;
|
|
} catch (x) { /* ignore malformed frame */ }
|
|
};
|
|
},
|
|
get visibleTickets() {
|
|
if (! this.activeStation) return this.tickets;
|
|
const sid = String(this.activeStation);
|
|
return this.tickets
|
|
.map((t) => ({ ...t, lines: t.lines.filter((l) => String(l.station_id || '') === sid) }))
|
|
.filter((t) => t.lines.length);
|
|
},
|
|
async load() {
|
|
try {
|
|
const res = await fetch(this.feedUrl, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
|
|
const data = await res.json();
|
|
this.tickets = data.tickets || [];
|
|
} catch (e) { /* keep last view on transient errors */ }
|
|
this.loaded = true;
|
|
},
|
|
elapsed(iso) {
|
|
if (! iso) return '';
|
|
return Math.max(0, Math.floor((this.now - new Date(iso).getTime()) / 60000)) + 'm';
|
|
},
|
|
nextLabel(state) { return ({ queued: 'Start', preparing: 'Ready', ready: 'Served' })[state] || 'Bump'; },
|
|
async bump(line) {
|
|
try {
|
|
await fetch(this.bumpUrl.replace('__ID__', line.id), {
|
|
method: 'POST', headers: { 'X-CSRF-TOKEN': this.csrf, Accept: 'application/json' },
|
|
});
|
|
await this.load();
|
|
} catch (e) { /* ignore */ }
|
|
},
|
|
}));
|
|
|
|
// Render a QR code into the element. config: { data, size }.
|
|
Alpine.data('posQr', (config = {}) => ({
|
|
init() {
|
|
if (! window.QRCodeStyling || ! config.data) return;
|
|
const qr = new window.QRCodeStyling({
|
|
width: config.size || 220, height: config.size || 220, margin: 6,
|
|
data: config.data,
|
|
dotsOptions: { type: 'rounded', color: '#0f172a' },
|
|
backgroundOptions: { color: '#ffffff' },
|
|
});
|
|
qr.append(this.$el);
|
|
},
|
|
}));
|
|
|
|
// Public table-QR ordering — a client-side cart that submits a guest order.
|
|
// config: { products, currency, orderUrl }.
|
|
Alpine.data('posTableOrder', (config = {}) => ({
|
|
products: config.products || [],
|
|
currency: config.currency || 'GHS',
|
|
orderUrl: config.orderUrl || '',
|
|
csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
search: '',
|
|
activeCategory: '',
|
|
cart: [],
|
|
cartOpen: false,
|
|
busy: false,
|
|
flash: '',
|
|
customerName: '',
|
|
picker: { open: false, product: null, groups: [], notes: '' },
|
|
|
|
money(minor) { return this.currency + ' ' + ((minor || 0) / 100).toFixed(2); },
|
|
get categories() {
|
|
const set = [];
|
|
this.products.forEach((p) => { if (p.category && ! set.includes(p.category)) set.push(p.category); });
|
|
return set;
|
|
},
|
|
get filteredProducts() {
|
|
const q = this.search.trim().toLowerCase();
|
|
return this.products.filter((p) =>
|
|
(! this.activeCategory || p.category === this.activeCategory)
|
|
&& (! q || p.name.toLowerCase().includes(q)));
|
|
},
|
|
get cartCount() { return this.cart.reduce((n, i) => n + i.quantity, 0); },
|
|
get cartTotal() { return this.cart.reduce((s, i) => s + i.unit_minor * i.quantity, 0); },
|
|
|
|
tap(p) {
|
|
if (p.modifier_groups && p.modifier_groups.length) this.openPicker(p);
|
|
else this.addToCart(p, { ids: [], names: [] });
|
|
},
|
|
openPicker(p) {
|
|
this.flash = '';
|
|
this.picker = { open: true, product: p, notes: '', groups: p.modifier_groups.map((g) => ({ ...g, selected: [] })) };
|
|
},
|
|
closePicker() { this.picker.open = false; },
|
|
isSelected(g, m) { return g.selected.includes(m.id); },
|
|
toggleModifier(g, m) {
|
|
const i = g.selected.indexOf(m.id);
|
|
if (i >= 0) { g.selected.splice(i, 1); return; }
|
|
if (g.max === 1) { g.selected = [m.id]; return; }
|
|
if (g.max && g.selected.length >= g.max) return;
|
|
g.selected.push(m.id);
|
|
},
|
|
get pickerPrice() {
|
|
if (! this.picker.product) return 0;
|
|
let total = this.picker.product.price_minor;
|
|
this.picker.groups.forEach((g) => g.modifiers.forEach((m) => { if (g.selected.includes(m.id)) total += m.price_delta_minor; }));
|
|
return total;
|
|
},
|
|
get pickerValid() { return this.picker.groups.every((g) => g.selected.length >= (g.min || 0)); },
|
|
confirmPicker() {
|
|
if (! this.pickerValid) { this.flash = 'Choose the required options.'; return; }
|
|
const ids = this.picker.groups.flatMap((g) => g.selected);
|
|
const names = this.picker.groups.flatMap((g) => g.modifiers.filter((m) => g.selected.includes(m.id)).map((m) => m.name));
|
|
this.addToCart(this.picker.product, { ids, names, notes: this.picker.notes });
|
|
this.closePicker();
|
|
},
|
|
addToCart(p, opts) {
|
|
let unit = p.price_minor;
|
|
(opts.ids || []).forEach((id) => p.modifier_groups.forEach((g) => g.modifiers.forEach((m) => { if (m.id === id) unit += m.price_delta_minor; })));
|
|
this.cart.push({
|
|
key: Date.now() + '-' + Math.random(),
|
|
product_id: p.id, name: p.name, quantity: 1,
|
|
modifier_ids: opts.ids || [], modifier_names: opts.names || [],
|
|
notes: opts.notes || '', unit_minor: unit,
|
|
});
|
|
this.cartOpen = true;
|
|
},
|
|
inc(i) { i.quantity++; },
|
|
dec(i) { i.quantity--; if (i.quantity <= 0) this.remove(i); },
|
|
remove(i) { this.cart = this.cart.filter((x) => x.key !== i.key); if (! this.cart.length) this.cartOpen = false; },
|
|
|
|
async submit() {
|
|
if (! this.cart.length || this.busy) return;
|
|
this.busy = true;
|
|
this.flash = '';
|
|
try {
|
|
const res = await fetch(this.orderUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf, Accept: 'application/json' },
|
|
body: JSON.stringify({
|
|
customer_name: this.customerName || null,
|
|
items: this.cart.map((i) => ({ product_id: i.product_id, quantity: i.quantity, modifier_ids: i.modifier_ids, notes: i.notes || null })),
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (! res.ok) { this.flash = data.message || 'Could not place the order.'; this.busy = false; return; }
|
|
window.location.href = data.redirect;
|
|
} catch (e) {
|
|
this.flash = 'Network error — try again.';
|
|
this.busy = false;
|
|
}
|
|
},
|
|
}));
|
|
|
|
window.Alpine = Alpine;
|
|
registerLadillConfirmStore(Alpine);
|
|
|
|
Alpine.start();
|