Faster voice announcements, Care/Frontdesk API, and console UI polish.
Deploy Ladill Queue / deploy (push) Successful in 28s

Poll displays every 1.2s with client-side TTS ack; expose service API for sibling apps; redesign tickets, counters, and staff console.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 21:51:13 +00:00
co-authored by Cursor
parent 051372672b
commit bc6bf0a07c
22 changed files with 813 additions and 140 deletions
+74 -10
View File
@@ -54,26 +54,90 @@ export function registerKioskFlow(Alpine) {
},
}));
Alpine.data('displayBoard', (dataUrl) => ({
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(dataUrl, { headers: { Accept: 'application/json' } });
const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } });
this.payload = await res.json();
if (this.payload?.announcements?.length && window.speechSynthesis) {
const msg = this.payload.announcements[0]?.message;
if (msg) {
const utter = new SpeechSynthesisUtterance(msg);
window.speechSynthesis.speak(utter);
}
}
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(), 5000);
setInterval(() => this.poll(), this.pollMs);
},
}));