export function registerKioskFlow(Alpine) { Alpine.data('kioskFlow', (config = {}) => ({ step: 'select', queueId: null, customerName: '', customerPhone: '', ticket: null, error: null, loading: false, issueUrl: config.issueUrl, csrf: config.csrf, selectQueue(id) { this.queueId = id; this.step = 'details'; }, async issue() { if (! this.queueId) return; this.loading = true; this.error = null; try { const res = await fetch(this.issueUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': this.csrf, }, body: JSON.stringify({ queue_id: this.queueId, customer_name: this.customerName || null, customer_phone: this.customerPhone || null, }), }); const data = await res.json(); if (! res.ok) throw new Error(data.message || 'Could not issue ticket'); this.ticket = data.data; this.step = 'ticket'; } catch (e) { this.error = e.message || 'Something went wrong'; } finally { this.loading = false; } }, reset() { this.step = 'select'; this.queueId = null; this.customerName = ''; this.customerPhone = ''; this.ticket = null; this.error = null; }, })); Alpine.data('displayBoard', (config = {}) => ({ payload: null, announcedIds: {}, isAnnouncing: false, dataUrl: typeof config === 'string' ? config : config.dataUrl, playedUrlTemplate: config.playedUrl ?? null, pollMs: config.pollMs ?? 1200, csrf: config.csrf ?? document.querySelector('meta[name="csrf-token"]')?.content ?? '', async poll() { try { const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } }); this.payload = await res.json(); this.maybeAnnounce(this.payload?.announcements ?? []); } catch (e) { console.error('Display poll failed', e); } }, maybeAnnounce(announcements) { if (! window.speechSynthesis || this.isAnnouncing || ! announcements.length) { return; } const next = announcements.find((item) => item?.id && item?.message && ! this.announcedIds[item.id]); if (! next) { return; } this.isAnnouncing = true; this.announcedIds[next.id] = true; this.speakAnnouncement(next); }, speakAnnouncement(item) { const repeats = Math.max(1, Number(item.repeat_count) || 1); const volume = Math.min(1, Math.max(0, (Number(item.volume) || 80) / 100)); let remaining = repeats; const finish = () => { this.isAnnouncing = false; this.ackPlayed(item.id); this.maybeAnnounce(this.payload?.announcements ?? []); }; const speakOnce = () => { window.speechSynthesis.resume?.(); const utter = new SpeechSynthesisUtterance(item.message); utter.volume = volume; utter.lang = item.locale === 'fr' ? 'fr-FR' : item.locale === 'tw' ? 'ak-GH' : 'en-US'; utter.onend = () => { remaining -= 1; if (remaining > 0) { speakOnce(); } else { finish(); } }; utter.onerror = () => { delete this.announcedIds[item.id]; finish(); }; window.speechSynthesis.speak(utter); }; speakOnce(); }, async ackPlayed(id) { if (! this.playedUrlTemplate) { return; } const url = this.playedUrlTemplate.replace('__ID__', String(id)); try { await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'X-CSRF-TOKEN': this.csrf, }, }); } catch (e) { console.error('Announcement ack failed', e); delete this.announcedIds[id]; } }, init() { this.poll(); setInterval(() => this.poll(), this.pollMs); }, })); Alpine.data('mobileTracker', (pollUrl) => ({ data: null, ahead: 0, async poll() { try { const res = await fetch(pollUrl, { headers: { Accept: 'application/json' } }); const json = await res.json(); this.data = json.data; this.ahead = json.customers_ahead ?? 0; } catch (e) { console.error('Tracker poll failed', e); } }, init() { this.poll(); setInterval(() => this.poll(), 10000); }, })); }