Deploy Ladill Queue / deploy (push) Successful in 1m56s
Remove newest-card highlighting so every active service point looks the same, sort the board stably by destination, and have the display announce whenever a counter’s ticket changes (falling back when a server voice row is missing). Co-authored-by: Cursor <cursoragent@cursor.com>
514 lines
16 KiB
JavaScript
514 lines
16 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: '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);
|
|
},
|
|
}));
|
|
}
|