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>
163 lines
5.3 KiB
JavaScript
163 lines
5.3 KiB
JavaScript
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,
|
|
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(this.dataUrl, { headers: { Accept: 'application/json' } });
|
|
this.payload = await res.json();
|
|
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(), 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);
|
|
},
|
|
}));
|
|
}
|