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: 'welcome', queueId: null, customerName: '', customerPhone: '', ticket: null, error: null, loading: false, idleTimer: null, countdownInterval: null, resetCountdown: null, issueUrl: config.issueUrl, csrf: config.csrf, queues: config.queues ?? [], collectName: config.collectName ?? false, collectPhone: config.collectPhone ?? false, resetSeconds: config.resetSeconds ?? 15, welcomeMessage: config.welcomeMessage ?? null, startTimer() { this.resetIdleTimer(); }, resetIdleTimer() { clearTimeout(this.idleTimer); clearInterval(this.countdownInterval); this.resetCountdown = this.resetSeconds; this.countdownInterval = setInterval(() => { this.resetCountdown -= 1; if (this.resetCountdown <= 0) { clearInterval(this.countdownInterval); this.countdownInterval = null; } }, 1000); this.idleTimer = setTimeout(() => this.reset(), this.resetSeconds * 1000); }, clearIdleTimer() { clearTimeout(this.idleTimer); clearInterval(this.countdownInterval); this.idleTimer = null; this.countdownInterval = null; this.resetCountdown = null; }, beginKiosk() { this.resetIdleTimer(); if (this.queues.length === 0) { this.step = 'select'; return; } if (this.queues.length === 1) { this.queueId = this.queues[0].id; if (! this.collectName && ! this.collectPhone) { this.issue(); return; } } this.step = 'select'; }, goBack() { this.resetIdleTimer(); this.error = null; if (this.step === 'details') { this.step = 'select'; return; } if (this.step === 'select') { this.step = 'welcome'; this.queueId = null; } }, selectQueue(id) { this.queueId = id; this.error = null; this.resetIdleTimer(); if (! this.collectName && ! this.collectPhone) { this.issue(); return; } this.step = 'details'; }, async issue() { if (! this.queueId || this.loading) { return; } this.loading = true; this.error = null; this.resetIdleTimer(); 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'; this.resetIdleTimer(); } catch (e) { this.error = e.message || 'Something went wrong'; } finally { this.loading = false; } }, reset() { this.clearIdleTimer(); this.step = 'welcome'; this.queueId = null; this.customerName = ''; this.customerPhone = ''; this.ticket = null; this.error = null; this.loading = false; this.startTimer(); }, formatWait(seconds) { if (! seconds) { return null; } const minutes = Math.ceil(seconds / 60); return minutes <= 1 ? 'About 1 minute' : `About ${minutes} minutes`; }, })); Alpine.data('displayBoard', (config = {}) => ({ payload: config.initial ?? null, announcedIds: {}, servingSignatures: {}, servingPrimed: false, pendingLocalAnnouncements: [], isAnnouncing: false, speechReady: false, voices: [], clock: '', dateLabel: '', pollError: null, 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 ?? '', formatCount(value) { return value === null || value === undefined ? '—' : String(value); }, formatWait(minutes) { return minutes === null || minutes === undefined ? '—' : String(minutes); }, async poll() { try { const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } }); if (! res.ok) { throw new Error(`Display poll failed (${res.status})`); } this.payload = await res.json(); this.pollError = null; this.trackServingChanges( this.payload?.now_serving ?? [], this.payload?.announcements ?? [], ); this.maybeAnnounce(this.payload?.announcements ?? []); } catch (e) { this.pollError = e.message || 'Could not refresh display data'; console.error('Display poll failed', e); } }, servingPointKey(item) { return item?.point_key || item?.destination || item?.counter || item?.queue_name || item?.ticket_number || 'unknown'; }, trackServingChanges(nowServing, serverAnnouncements = []) { const nextSignatures = {}; const changed = []; for (const item of nowServing) { if (! item?.ticket_number) { continue; } const key = this.servingPointKey(item); const signature = `${item.ticket_number}|${item.called_at || ''}`; nextSignatures[key] = signature; if (this.servingPrimed && this.servingSignatures[key] !== signature) { changed.push(item); } } this.servingSignatures = nextSignatures; if (! this.servingPrimed) { this.servingPrimed = true; return; } for (const item of changed) { const ticket = String(item.ticket_number); const coveredByServer = (serverAnnouncements || []).some( (announcement) => announcement?.message && String(announcement.message).includes(ticket), ); if (coveredByServer) { continue; } const message = item.announcement_text || `Ticket ${item.ticket_number}, please proceed to ${item.destination || item.counter || 'the service point'}.`; const localId = `local:${this.servingPointKey(item)}:${item.ticket_number}:${item.called_at || Date.now()}`; if (this.announcedIds[localId]) { continue; } this.pendingLocalAnnouncements.push({ id: localId, message, locale: 'en', voice: 'female', volume: 80, repeat_count: 1, local: true, }); } }, 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) { return; } const queue = [ ...(Array.isArray(announcements) ? announcements : []), ...this.pendingLocalAnnouncements, ]; const next = queue.find((item) => item?.id && item?.message && ! this.announcedIds[item.id]); if (! next) { return; } this.isAnnouncing = true; this.announcedIds[next.id] = true; this.pendingLocalAnnouncements = this.pendingLocalAnnouncements.filter((item) => item.id !== next.id); 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(); if (! item.local) { this.ackPlayed(item.id); } this.maybeAnnounce([ ...(this.payload?.announcements ?? []), ...this.pendingLocalAnnouncements, ]); }; 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(); this.updateClock(); setInterval(() => this.updateClock(), 1000); try { if (sessionStorage.getItem(DISPLAY_AUDIO_KEY) === '1') { this.unlockSpeech(); } } catch (e) { // ignore storage failures } // Seed signatures from SSR payload without announcing historical tickets. this.trackServingChanges(this.payload?.now_serving ?? []); this.poll(); setInterval(() => this.poll(), this.pollMs); }, updateClock() { const now = new Date(); this.clock = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); this.dateLabel = now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', }); }, })); 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); }, })); }