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, spokenIds: new Set(), speaking: 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(); await this.handleAnnouncements(this.payload?.announcements ?? []); } catch (e) { console.error('Display poll failed', e); } }, async handleAnnouncements(announcements) { if (! window.speechSynthesis || this.speaking) return; for (const item of announcements) { if (! item?.id || ! item?.message || this.spokenIds.has(item.id)) { continue; } this.speaking = true; try { await this.speakAnnouncement(item); this.spokenIds.add(item.id); await this.ackPlayed(item.id); } finally { this.speaking = false; } } }, 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)); return new Promise((resolve) => { let remaining = repeats; const speakOnce = () => { 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 { resolve(); } }; utter.onerror = () => resolve(); 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); } }, 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); }, })); }