const DISPLAY_AUDIO_KEY = 'qms_display_audio'; function localeToSpeechLang(locale) { if (locale === 'fr') return 'fr-FR'; if (locale === 'tw') return 'ak-GH'; return 'en-US'; } function pickVoice(voices, locale, preference) { const list = voices.length ? voices : window.speechSynthesis?.getVoices() ?? []; if (! list.length) { return null; } const lang = localeToSpeechLang(locale).toLowerCase(); const langMatches = list.filter((voice) => voice.lang?.toLowerCase().startsWith(lang.slice(0, 2))); const pool = langMatches.length ? langMatches : list; const wantFemale = preference !== 'male'; const scored = pool.map((voice) => { const name = voice.name.toLowerCase(); const female = /female|woman|zira|samantha|karen|victoria|fiona|moira|tessa|veena|lekha/.test(name); const male = /male|man|david|daniel|james|fred|thomas|lee|ralph/.test(name); let score = 0; if (wantFemale && female) score += 2; if (! wantFemale && male) score += 2; if (voice.default) score += 1; if (voice.localService) score += 1; return { voice, score }; }); scored.sort((a, b) => b.score - a.score); return scored[0]?.voice ?? pool[0] ?? null; } 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, speechReady: false, voices: [], 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); } }, primeSpeech() { if (! window.speechSynthesis) { return; } const loadVoices = () => { this.voices = window.speechSynthesis.getVoices() || []; }; loadVoices(); window.speechSynthesis.addEventListener('voiceschanged', loadVoices); }, unlockSpeech() { if (! window.speechSynthesis) { return; } window.speechSynthesis.resume?.(); window.speechSynthesis.cancel(); const utter = new SpeechSynthesisUtterance(' '); utter.volume = 0; utter.onend = () => this.markSpeechReady(); utter.onerror = () => this.markSpeechReady(); window.speechSynthesis.speak(utter); }, markSpeechReady() { this.speechReady = true; try { sessionStorage.setItem(DISPLAY_AUDIO_KEY, '1'); } catch (e) { // ignore storage failures } this.maybeAnnounce(this.payload?.announcements ?? []); }, maybeAnnounce(announcements) { if (! window.speechSynthesis || ! this.speechReady || 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; let watchdog = null; const release = () => { if (watchdog) { clearTimeout(watchdog); watchdog = null; } this.isAnnouncing = false; }; const finish = () => { release(); this.ackPlayed(item.id); this.maybeAnnounce(this.payload?.announcements ?? []); }; const fail = (reason) => { console.error('Announcement speech failed', reason); release(); delete this.announcedIds[item.id]; }; const speakOnce = () => { window.speechSynthesis.resume?.(); window.speechSynthesis.cancel(); const utter = new SpeechSynthesisUtterance(item.message); utter.volume = volume; utter.lang = localeToSpeechLang(item.locale); const voice = pickVoice(this.voices, item.locale, item.voice); if (voice) { utter.voice = voice; } const estimatedMs = Math.max(4000, (item.message?.length || 24) * 140); watchdog = setTimeout(() => { if (! this.isAnnouncing) { return; } window.speechSynthesis.cancel(); remaining -= 1; if (remaining > 0) { speakOnce(); } else { finish(); } }, estimatedMs); utter.onend = () => { if (watchdog) { clearTimeout(watchdog); watchdog = null; } remaining -= 1; if (remaining > 0) { speakOnce(); } else { finish(); } }; utter.onerror = (event) => fail(event); window.speechSynthesis.speak(utter); }; speakOnce(); }, async ackPlayed(id) { if (! this.playedUrlTemplate) { return; } const url = this.playedUrlTemplate.replace('__ID__', String(id)); try { const res = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'X-CSRF-TOKEN': this.csrf, }, }); if (! res.ok) { throw new Error(`Ack failed (${res.status})`); } } catch (e) { console.error('Announcement ack failed', e); delete this.announcedIds[id]; } }, init() { this.primeSpeech(); try { if (sessionStorage.getItem(DISPLAY_AUDIO_KEY) === '1') { this.unlockSpeech(); } } catch (e) { // ignore storage failures } 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); }, })); }