diff --git a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css
index 1a3a72a..cf50fa6 100644
--- a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css
+++ b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css
@@ -280,31 +280,36 @@
margin: 0;
padding: 0;
display: flex;
- flex-direction: column;
- gap: 0.45em;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: stretch;
+ gap: 0.55em;
flex: 1;
min-height: 0;
- overflow: auto;
+ overflow: hidden;
}
.branch-display__waiting-item {
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 0.75em;
- padding: 0.55em 0.85em;
+ justify-content: center;
+ flex: 1 1 0;
+ min-width: 0;
+ padding: 0.55em 0.45em;
border-radius: 0.4em;
background: var(--bd-surface);
border: 1px solid var(--bd-border);
- border-left: 0.2em solid var(--bd-green);
- flex-shrink: 0;
+ border-bottom: 0.2em solid var(--bd-green);
}
.branch-display__waiting-number {
- font-size: clamp(1.35em, 2.4vw, 2.5em);
+ font-size: clamp(1.2em, 2vw, 2.25em);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--bd-accent);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.branch-display__waiting-service {
diff --git a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx
index 57c632a..a23df48 100644
--- a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx
+++ b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx
@@ -214,6 +214,8 @@ export default function BranchDisplayPage() {
[tickets]
);
+ const waitingPreview = useMemo(() => waiting.slice(0, 5), [waiting]);
+
const sortedStations = useMemo(
() => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))),
[stations]
@@ -285,7 +287,7 @@ export default function BranchDisplayPage() {
Tiada nombor dalam giliran.
) : (
- {waiting.map((ticket) => (
+ {waitingPreview.map((ticket) => (
-
{ticket.number}
diff --git a/fe/web/teller-app/src/utils/queueCallSound.js b/fe/web/teller-app/src/utils/queueCallSound.js
index 03c4334..9421c49 100644
--- a/fe/web/teller-app/src/utils/queueCallSound.js
+++ b/fe/web/teller-app/src/utils/queueCallSound.js
@@ -122,7 +122,8 @@ export function getNewServingCalls(previous, nextOrTickets) {
calls.push({
stationId,
ticketNumber: number,
- stationName: ticket.station.name ?? `Stesen ${stationId}`,
+ // Leave unset when missing so speech can pick Stesen/Station by locale.
+ stationName: ticket.station.name ?? undefined,
});
}
diff --git a/fe/web/teller-app/src/utils/queueCallSpeech.js b/fe/web/teller-app/src/utils/queueCallSpeech.js
index 58f02ba..da5fa73 100644
--- a/fe/web/teller-app/src/utils/queueCallSpeech.js
+++ b/fe/web/teller-app/src/utils/queueCallSpeech.js
@@ -1,4 +1,5 @@
const MALAY_LANG = 'ms-MY';
+const ENGLISH_LANG = 'en-US';
const MALAY_DIGITS = [
'kosong',
@@ -13,6 +14,19 @@ const MALAY_DIGITS = [
'sembilan',
];
+const ENGLISH_DIGITS = [
+ 'zero',
+ 'one',
+ 'two',
+ 'three',
+ 'four',
+ 'five',
+ 'six',
+ 'seven',
+ 'eight',
+ 'nine',
+];
+
let speechUnlocked = false;
let speechQueue = [];
let speaking = false;
@@ -35,27 +49,68 @@ function pickMalayVoice() {
);
}
+function pickEnglishVoice() {
+ const voices = loadVoices();
+ return (
+ voices.find((voice) => voice.lang === ENGLISH_LANG) ||
+ voices.find((voice) => voice.lang.startsWith('en')) ||
+ null
+ );
+}
+
/**
- * Spell ticket numbers digit-by-digit in Malay for clearer announcements.
+ * Prefer Malay when a Malay voice pack is installed; otherwise fall back to English
+ * (common on Windows branches without the Malay language pack).
*/
-export function formatTicketNumberForSpeech(ticketNumber) {
+function resolveSpeechLocale() {
+ const malayVoice = pickMalayVoice();
+ if (malayVoice) {
+ return { lang: MALAY_LANG, voice: malayVoice, locale: 'ms' };
+ }
+
+ const englishVoice = pickEnglishVoice();
+ return {
+ lang: ENGLISH_LANG,
+ voice: englishVoice,
+ locale: 'en',
+ };
+}
+
+function formatTicketNumberForSpeech(ticketNumber, digits) {
return String(ticketNumber)
.trim()
.split('')
.map((character) => {
if (/\d/.test(character)) {
- return MALAY_DIGITS[Number(character)];
+ return digits[Number(character)];
}
return character;
})
.join(' ');
}
+function resolveStationName(stationName, stationId, locale) {
+ if (stationName) return stationName;
+ return locale === 'ms' ? `Stesen ${stationId}` : `Station ${stationId}`;
+}
+
export function formatMalayAnnouncement(ticketNumber, stationName) {
- const spokenNumber = formatTicketNumberForSpeech(ticketNumber);
+ const spokenNumber = formatTicketNumberForSpeech(ticketNumber, MALAY_DIGITS);
return `Nombor giliran ${spokenNumber}, sila ke ${stationName}.`;
}
+export function formatEnglishAnnouncement(ticketNumber, stationName) {
+ const spokenNumber = formatTicketNumberForSpeech(ticketNumber, ENGLISH_DIGITS);
+ return `Queue number ${spokenNumber}, please proceed to ${stationName}.`;
+}
+
+function formatAnnouncement(ticketNumber, stationName, locale) {
+ if (locale === 'ms') {
+ return formatMalayAnnouncement(ticketNumber, stationName);
+ }
+ return formatEnglishAnnouncement(ticketNumber, stationName);
+}
+
/**
* Call once after a user gesture so later speech is allowed on strict browsers.
*/
@@ -64,9 +119,10 @@ export function unlockQueueSpeech() {
try {
loadVoices();
+ const { lang } = resolveSpeechLocale();
const utterance = new SpeechSynthesisUtterance('');
utterance.volume = 0;
- utterance.lang = MALAY_LANG;
+ utterance.lang = lang;
window.speechSynthesis.speak(utterance);
} catch {
// Ignore unlock failures; real announcements will still be attempted.
@@ -79,11 +135,10 @@ function processSpeechQueue() {
if (speaking || speechQueue.length === 0 || !speechSynthesisAvailable()) return;
speaking = true;
- const text = speechQueue.shift();
+ const { text, lang, voice } = speechQueue.shift();
const utterance = new SpeechSynthesisUtterance(text);
- utterance.lang = MALAY_LANG;
+ utterance.lang = lang;
- const voice = pickMalayVoice();
if (voice) {
utterance.voice = voice;
}
@@ -105,18 +160,28 @@ function processSpeechQueue() {
}
/**
- * Queue Malay voice announcements for one or more station calls.
- * @param {{ ticketNumber: string, stationName: string }[]} calls
+ * Queue voice announcements for one or more station calls.
+ * Uses Malay when available, otherwise English.
+ * @param {{ ticketNumber: string, stationName?: string, stationId?: string }[]} calls
*/
export function announceQueueCalls(calls) {
if (!calls?.length || !speechSynthesisAvailable()) return;
unlockQueueSpeech();
+ const { lang, voice, locale } = resolveSpeechLocale();
+
for (const call of calls) {
- speechQueue.push(
- formatMalayAnnouncement(call.ticketNumber, call.stationName)
+ const stationName = resolveStationName(
+ call.stationName,
+ call.stationId,
+ locale
);
+ speechQueue.push({
+ text: formatAnnouncement(call.ticketNumber, stationName, locale),
+ lang,
+ voice,
+ });
}
processSpeechQueue();