Files
ladill-queue/resources/js/kiosk-flow.js
T
isaaccladandCursor af764f77fb
Deploy Ladill Queue / deploy (push) Successful in 28s
Fix display voice prompts requiring browser unlock and failed acks.
Add tap-to-enable speech overlay, stop marking announcements played on TTS errors, pick voices by locale, and scope pending announcements to the display's queues.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 22:21:56 +00:00

303 lines
9.5 KiB
JavaScript

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);
},
}));
}