2 Commits

Author SHA1 Message Date
ismailmasseran a4fb7b4d8b DONE: add malay voice read, enlarge the station name (#5)
Build Docker Image / build-admin (push) Successful in 19s
Build Docker Image / build-teller (push) Successful in 9s
Build Docker Image / build-customer (push) Successful in 5s
Build Docker Image / build-backend (push) Successful in 2m13s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #5
2026-09-01 16:09:51 +08:00
ismailmasseran 0420194541 Bugfix/testing (#4)
Build Docker Image / build-backend (push) Successful in 58s
Build Docker Image / build-admin (push) Successful in 6s
Build Docker Image / build-teller (push) Successful in 8s
Build Docker Image / build-customer (push) Successful in 2m6s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #4
2026-07-30 16:22:51 +08:00
11 changed files with 408 additions and 133 deletions
+2
View File
@@ -0,0 +1,2 @@
[x] create qr and teller account
[x] increase size of gold display
+1 -30
View File
@@ -4,7 +4,7 @@ services:
container_name: qms-mysql container_name: qms-mysql
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${MYSQL_HOST_PORT:-3307}:3306" - "3306:3306"
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms} MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
@@ -27,37 +27,8 @@ services:
networks: networks:
- qms-net - qms-net
backend:
image: ${BACKEND_IMAGE:-qms-backend}:${IMAGE_TAG:-local}
build:
context: .
dockerfile: Dockerfile
container_name: qms-backend
restart: unless-stopped
ports:
# Host port can be changed via BACKEND_HOST_PORT in .env (default 8080)
- "${BACKEND_HOST_PORT:-8080}:8080"
environment:
SERVER_PORT: 8080
# Use Docker service name "mysql", not localhost
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-qms}?allowPublicKeyRetrieval=true&useSSL=false
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF}
ADS_UPLOAD_DIR: /app/uploads/ads
NOTIFICATIONS_MOCK: ${NOTIFICATIONS_MOCK:-true}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-dummy-google-client-id}
volumes:
- qms-uploads:/app/uploads
depends_on:
mysql:
condition: service_healthy
networks:
- qms-net
volumes: volumes:
qms-mysql-data: qms-mysql-data:
qms-uploads:
networks: networks:
qms-net: qms-net:
@@ -33,13 +33,14 @@ public class DefaultAdminService implements AdminService {
private final RoleService roleService; private final RoleService roleService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuthService authService; private final AuthService authService;
public DefaultAdminService(final UserRepository userRepository, public DefaultAdminService(final UserRepository userRepository,
final UserService userService, final UserService userService,
final TwoFactorService twoFactorService, final TwoFactorService twoFactorService,
final TenantService tenantService, final TenantService tenantService,
final RoleService roleService, final RoleService roleService,
final PasswordEncoder passwordEncoder, final PasswordEncoder passwordEncoder,
final AuthService authService) { final AuthService authService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.userService = userService; this.userService = userService;
this.twoFactorService = twoFactorService; this.twoFactorService = twoFactorService;
@@ -50,7 +51,7 @@ public class DefaultAdminService implements AdminService {
} }
@Override @Override
public List<User> findUsersByCode(final String tenantCode, final String roleName){ public List<User> findUsersByCode(final String tenantCode, final String roleName) {
final Set<RoleName> roleNameSet = Set.of(RoleName.valueOf(roleName)); final Set<RoleName> roleNameSet = Set.of(RoleName.valueOf(roleName));
return this.userRepository.findAllByTenant_CodeAndRoles_NameIn(tenantCode, roleNameSet); return this.userRepository.findAllByTenant_CodeAndRoles_NameIn(tenantCode, roleNameSet);
} }
@@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -30,7 +31,8 @@ public class AdminController {
@PostMapping("/{code}") @PostMapping("/{code}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity getUsers(@RequestBody RoleRequest request, @PathVariable(name = "code") final String tenantCode) { public ResponseEntity<List<UserDto>> getUsers(@RequestBody RoleRequest request,
@PathVariable(name = "code") final String tenantCode) {
RoleName roleName; RoleName roleName;
try { try {
roleName = RoleName.valueOf(request.roleName); roleName = RoleName.valueOf(request.roleName);
@@ -39,7 +41,7 @@ public class AdminController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if(this.authService.canOnlyCRUDUser(roleName)){ if (this.authService.canOnlyCRUDUser(roleName)) {
logger.warn("Only super admin can read admins"); logger.warn("Only super admin can read admins");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -52,8 +54,7 @@ public class AdminController {
return ResponseEntity.ok().body( return ResponseEntity.ok().body(
this.adminService.findUsersByCode(tenantCode, request.roleName).stream() this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
.map(UserDto::fromEntity) .map(UserDto::fromEntity)
.collect(Collectors.toList()) .collect(Collectors.toList()));
);
} catch (EntityNotFoundException e) { } catch (EntityNotFoundException e) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -61,7 +62,8 @@ public class AdminController {
@PostMapping("/{code}/user") @PostMapping("/{code}/user")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity addUser(@RequestBody final AdminRequest request, @PathVariable(name = "code") final String tenantCode) throws AuthException { public ResponseEntity<UserDto> addUser(@RequestBody final AdminRequest request,
@PathVariable(name = "code") final String tenantCode) throws AuthException {
RoleName roleName; RoleName roleName;
try { try {
roleName = RoleName.valueOf(request.roleName); roleName = RoleName.valueOf(request.roleName);
@@ -70,7 +72,7 @@ public class AdminController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if(this.authService.canOnlyCRUDUser(roleName)){ if (this.authService.canOnlyCRUDUser(roleName)) {
logger.warn("Only super admin can add admin"); logger.warn("Only super admin can add admin");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -95,7 +97,8 @@ public class AdminController {
@PutMapping("/{code}/user/{userId}") @PutMapping("/{code}/user/{userId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity updateAdmin(@RequestBody final UserDto request, @PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) { public ResponseEntity<SimpleMessageDto> updateAdmin(@RequestBody final UserDto request,
@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
logger.warn("Admin does not belong to the specified tenant"); logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
@@ -112,7 +115,8 @@ public class AdminController {
@DeleteMapping("/{code}/user/{userId}") @DeleteMapping("/{code}/user/{userId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity removeAdmin(@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) { public ResponseEntity<SimpleMessageDto> removeAdmin(@PathVariable(name = "code") final String tenantCode,
@PathVariable(name = "userId") final long adminId) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
logger.warn("Admin does not belong to the specified tenant"); logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
@@ -130,6 +134,6 @@ public class AdminController {
public record AdminRequest(String email, String password, String roleName) { public record AdminRequest(String email, String password, String roleName) {
} }
public record RoleRequest(String roleName){ public record RoleRequest(String roleName) {
} }
} }
@@ -30,13 +30,13 @@ public class AdvertisementController {
private final AuthService authService; private final AuthService authService;
public AdvertisementController(final AdvertisementService advertisementService, public AdvertisementController(final AdvertisementService advertisementService,
final AuthService authService) { final AuthService authService) {
this.advertisementService = advertisementService; this.advertisementService = advertisementService;
this.authService = authService; this.authService = authService;
} }
@GetMapping("/media/{adId}") @GetMapping("/media/{adId}")
public ResponseEntity streamMedia(@PathVariable final long adId) { public ResponseEntity<Resource> streamMedia(@PathVariable final long adId) {
try { try {
final Advertisement advertisement = this.advertisementService.findById(adId); final Advertisement advertisement = this.advertisementService.findById(adId);
final Resource resource = this.advertisementService.loadMedia(adId); final Resource resource = this.advertisementService.loadMedia(adId);
@@ -64,11 +64,11 @@ public class AdvertisementController {
@PostMapping("/{tenantCode}") @PostMapping("/{tenantCode}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity createAdvertisement(@PathVariable final String tenantCode, public ResponseEntity createAdvertisement(@PathVariable final String tenantCode,
@RequestParam("file") final MultipartFile file, @RequestParam("file") final MultipartFile file,
@RequestParam(value = "title", required = false) final String title, @RequestParam(value = "title", required = false) final String title,
@RequestParam(value = "durationSeconds", required = false) final Integer durationSeconds, @RequestParam(value = "durationSeconds", required = false) final Integer durationSeconds,
@RequestParam(value = "sortOrder", required = false) final Integer sortOrder, @RequestParam(value = "sortOrder", required = false) final Integer sortOrder,
@RequestParam(value = "active", required = false) final Boolean active) { @RequestParam(value = "active", required = false) final Boolean active) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -80,8 +80,7 @@ public class AdvertisementController {
title, title,
durationSeconds, durationSeconds,
sortOrder, sortOrder,
active active);
);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created)); return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created));
} catch (final Exception exception) { } catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage())); return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -108,8 +107,8 @@ public class AdvertisementController {
@PutMapping("/{tenantCode}/{adId}") @PutMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity updateAdvertisement(@PathVariable final String tenantCode, public ResponseEntity updateAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId, @PathVariable final long adId,
@RequestBody final AdvertisementUpdateRequest request) { @RequestBody final AdvertisementUpdateRequest request) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -121,8 +120,7 @@ public class AdvertisementController {
request.title(), request.title(),
request.durationSeconds(), request.durationSeconds(),
request.sortOrder(), request.sortOrder(),
request.active() request.active());
);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated)); return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated));
} catch (final Exception exception) { } catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage())); return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -132,7 +130,7 @@ public class AdvertisementController {
@DeleteMapping("/{tenantCode}/{adId}") @DeleteMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity deleteAdvertisement(@PathVariable final String tenantCode, public ResponseEntity deleteAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId) { @PathVariable final long adId) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -146,8 +144,8 @@ public class AdvertisementController {
} }
public record AdvertisementUpdateRequest(String title, public record AdvertisementUpdateRequest(String title,
Integer durationSeconds, Integer durationSeconds,
Integer sortOrder, Integer sortOrder,
Boolean active) { Boolean active) {
} }
} }
@@ -22,6 +22,9 @@ type Props = {
initialBranchId?: number; initialBranchId?: number;
}; };
/** Prevents spam of "Dapatkan tiket lain" right after getting a number. */
const ANOTHER_TICKET_COOLDOWN_MS = 10_000;
function pickLatestTicket( function pickLatestTicket(
tickets: Ticket[], tickets: Ticket[],
branchId?: number branchId?: number
@@ -61,8 +64,27 @@ export default function CustomerTicketFlow({
const [ticket, setTicket] = useState<Ticket | null>(null); const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cooldownLeftMs, setCooldownLeftMs] = useState(0);
const bootstrapped = useRef(false); const bootstrapped = useRef(false);
useEffect(() => {
if (step !== "ticket" || !ticket) {
setCooldownLeftMs(0);
return;
}
const tick = () => {
const elapsed = Date.now() - new Date(ticket.createdAt).getTime();
setCooldownLeftMs(
Math.max(0, ANOTHER_TICKET_COOLDOWN_MS - elapsed)
);
};
tick();
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, [step, ticket]);
useEffect(() => { useEffect(() => {
if (bootstrapped.current) return; if (bootstrapped.current) return;
bootstrapped.current = true; bootstrapped.current = true;
@@ -281,6 +303,9 @@ export default function CustomerTicketFlow({
? "Pilih perkhidmatan untuk mendapatkan nombor." ? "Pilih perkhidmatan untuk mendapatkan nombor."
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah."; : "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
const canGetAnotherTicket = cooldownLeftMs <= 0;
const cooldownSeconds = Math.ceil(cooldownLeftMs / 1000);
return ( return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12"> <main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12">
<header className="space-y-2"> <header className="space-y-2">
@@ -423,7 +448,7 @@ export default function CustomerTicketFlow({
{step === "ticket" && ticket ? ( {step === "ticket" && ticket ? (
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center"> <section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center">
<p className="text-sm tracking-wide text-zinc-500 uppercase"> <p className="text-sm tracking-wide text-zinc-500 uppercase">
Nombor tiket anda Nombor giliran anda
</p> </p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900"> <p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number} {ticket.number}
@@ -432,12 +457,36 @@ export default function CustomerTicketFlow({
<p>{ticket.service.name}</p> <p>{ticket.service.name}</p>
<p>{ticket.branch.name}</p> <p>{ticket.branch.name}</p>
</div> </div>
<div
className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-left text-sm text-amber-950"
role="status"
>
<p className="font-semibold">Anda sudah mempunyai nombor giliran.</p>
<p className="mt-1 text-amber-900">
Sila tunggu sehingga nombor anda dipanggil.
</p>
{!canGetAnotherTicket ? (
<p className="mt-2 font-medium tabular-nums text-amber-950">
Tiket lain boleh diambil dalam {cooldownSeconds}s
</p>
) : null}
</div>
<button <button
type="button" type="button"
onClick={resetFlow} onClick={resetFlow}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white" disabled={!canGetAnotherTicket || loading}
aria-disabled={!canGetAnotherTicket || loading}
className={
canGetAnotherTicket
? "w-full rounded-md border border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-700 transition hover:border-zinc-500 hover:bg-zinc-50 disabled:opacity-60"
: "w-full cursor-not-allowed rounded-md bg-zinc-200 px-4 py-3 text-sm font-medium text-zinc-500"
}
> >
Dapatkan tiket lain {canGetAnotherTicket
? "Dapatkan nombor giliran lain"
: `Tunggu ${cooldownSeconds}s…`}
</button> </button>
</section> </section>
) : null} ) : null}
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --webpack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
@@ -15,11 +15,15 @@
--bd-error-bg: #5c1a1a; --bd-error-bg: #5c1a1a;
--bd-error-text: #fecaca; --bd-error-text: #fecaca;
/* Fluid root: scales 720p → 4K; descendants use em so they follow */
font-size: clamp(12px, 0.75vw + 0.55vh, 28px);
position: relative; position: relative;
height: 100vh; height: 100vh;
height: 100dvh;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 1rem 1.5rem 1rem; padding: 0.75em 1em;
background: background:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%), radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%), radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
@@ -28,7 +32,7 @@
font-family: system-ui, -apple-system, Segoe UI, sans-serif; font-family: system-ui, -apple-system, Segoe UI, sans-serif;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
gap: 0.75rem; gap: 0.6em;
overflow: hidden; overflow: hidden;
} }
@@ -37,28 +41,28 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1em;
} }
.branch-display__brand { .branch-display__brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.85rem; gap: 0.85em;
min-width: 0; min-width: 0;
} }
.branch-display__logo { .branch-display__logo {
height: clamp(2.5rem, 4.5vw, 3.5rem); height: clamp(2em, 1.8em + 1vw, 4em);
width: auto; width: auto;
flex-shrink: 0; flex-shrink: 0;
border-radius: 0.35rem; border-radius: 0.35em;
object-fit: contain; object-fit: contain;
background: #fff; background: #fff;
} }
.branch-display__eyebrow { .branch-display__eyebrow {
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 0.75em;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--bd-accent); color: var(--bd-accent);
@@ -67,7 +71,7 @@
.branch-display__title { .branch-display__title {
margin: 0; margin: 0;
font-size: clamp(1.4rem, 2.2vw, 2rem); font-size: clamp(1.25em, 2vw, 1.75em);
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--bd-text); color: var(--bd-text);
@@ -76,17 +80,17 @@
.branch-display__error { .branch-display__error {
margin: 0; margin: 0;
padding: 0.5rem 0.75rem; padding: 0.5em 0.75em;
border-radius: 0.4rem; border-radius: 0.4em;
background: var(--bd-error-bg); background: var(--bd-error-bg);
color: var(--bd-error-text); color: var(--bd-error-text);
font-size: 0.9rem; font-size: 0.9em;
} }
.branch-display__main { .branch-display__main {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 0.85rem; gap: 0.85em;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -94,6 +98,7 @@
@media (max-width: 960px) { @media (max-width: 960px) {
.branch-display { .branch-display {
height: auto; height: auto;
min-height: 100dvh;
min-height: 100vh; min-height: 100vh;
overflow: auto; overflow: auto;
grid-template-rows: auto; grid-template-rows: auto;
@@ -110,7 +115,7 @@
.branch-display__section--gold { .branch-display__section--gold {
flex: none; flex: none;
min-height: 16rem; min-height: 16em;
} }
} }
@@ -119,7 +124,7 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: 0.75em;
overflow: hidden; overflow: hidden;
} }
@@ -128,12 +133,23 @@
min-height: 0; min-height: 0;
} }
/* Serving block must keep its content height — otherwise cards spill into Giliran */
.branch-display__queue-column > .branch-display__section:first-of-type {
flex: 0 0 auto;
min-height: auto;
overflow: hidden;
}
.branch-display__section--waiting { .branch-display__section--waiting {
flex: 0 1 auto; flex: 0 1 auto;
max-height: 28%; max-height: 28%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
position: relative;
z-index: 1;
background: transparent;
} }
.branch-display__section--gold { .branch-display__section--gold {
@@ -141,11 +157,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.branch-display__section-title { .branch-display__section-title {
margin: 0 0 0.45rem; margin: 0 0 0.45em;
font-size: 0.85rem; font-size: 1.05em;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@@ -154,16 +171,16 @@
.branch-display__stations { .branch-display__stations {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 10em), 1fr));
gap: 0.55rem; gap: 0.7em;
} }
.branch-display__station-card { .branch-display__station-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.2rem; gap: 0.35em;
padding: 0.65rem 0.5rem; padding: 0.9em 0.65em;
border-radius: 0.5rem; border-radius: 0.5em;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%); background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08); box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
@@ -172,18 +189,51 @@
.branch-display__station-name { .branch-display__station-name {
margin: 0; margin: 0;
font-size: 0.8rem; padding: 0.2em 0.45em;
color: var(--bd-muted); font-size: clamp(1.35em, min(3.2vw, 5.5vh), 2.75em);
font-weight: 700;
line-height: 1.15;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--bd-text);
background: rgba(7, 26, 16, 0.45);
border-radius: 0.25em;
border: 1px solid var(--bd-border-strong);
} }
.branch-display__ticket-number { .branch-display__ticket-number {
margin: 0; margin: 0;
font-size: clamp(1.75rem, 3.2vw, 2.75rem); font-size: clamp(1.75em, min(4.2vw, 8vh), 5.5em);
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
letter-spacing: -0.03em; letter-spacing: -0.03em;
color: var(--bd-accent-bright); color: var(--bd-accent-bright);
text-shadow: 0 0 24px rgba(255, 229, 102, 0.25); text-shadow: 0 0 1.5em rgba(255, 229, 102, 0.25);
}
/* Short TVs (e.g. 720p): tighten serving cards so sections don't collide */
@media (max-height: 800px) {
.branch-display__station-card {
padding: 0.55em 0.5em;
gap: 0.2em;
}
.branch-display__station-name {
font-size: clamp(1.15em, min(2.6vw, 4.5vh), 2em);
padding: 0.15em 0.35em;
}
.branch-display__ticket-number {
font-size: clamp(1.5em, min(3.6vw, 7vh), 3.25em);
}
.branch-display__section-title {
margin-bottom: 0.3em;
}
.branch-display__section--waiting {
max-height: 24%;
}
} }
.branch-display__ticket-number--idle { .branch-display__ticket-number--idle {
@@ -193,7 +243,7 @@
.branch-display__station-service { .branch-display__station-service {
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 1em;
color: var(--bd-muted-strong); color: var(--bd-muted-strong);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -204,8 +254,8 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75em;
margin-bottom: 0.4rem; margin-bottom: 0.4em;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -215,14 +265,14 @@
.branch-display__waiting-count { .branch-display__waiting-count {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 1.05em;
color: var(--bd-accent); color: var(--bd-accent);
} }
.branch-display__empty { .branch-display__empty {
margin: 0; margin: 0;
color: var(--bd-muted); color: var(--bd-muted);
font-size: 0.9rem; font-size: 1.05em;
} }
.branch-display__waiting-list { .branch-display__waiting-list {
@@ -231,7 +281,7 @@
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.45em;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
@@ -241,24 +291,24 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75em;
padding: 0.4rem 0.7rem; padding: 0.55em 0.85em;
border-radius: 0.4rem; border-radius: 0.4em;
background: var(--bd-surface); background: var(--bd-surface);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
border-left: 3px solid var(--bd-green); border-left: 0.2em solid var(--bd-green);
flex-shrink: 0; flex-shrink: 0;
} }
.branch-display__waiting-number { .branch-display__waiting-number {
font-size: clamp(1.1rem, 1.8vw, 1.5rem); font-size: clamp(1.35em, 2.4vw, 2.5em);
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--bd-accent); color: var(--bd-accent);
} }
.branch-display__waiting-service { .branch-display__waiting-service {
font-size: 0.85rem; font-size: 0.85em;
color: var(--bd-muted-strong); color: var(--bd-muted-strong);
} }
@@ -266,8 +316,8 @@
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
padding: 0.65rem; padding: 0.65em;
border-radius: 0.6rem; border-radius: 0.6em;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%); background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06); box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
@@ -280,8 +330,8 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75em;
margin-bottom: 0.4rem; margin-bottom: 0.4em;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -294,7 +344,7 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
width: 100%; width: 100%;
border-radius: 0.4rem; border-radius: 0.4em;
overflow: hidden; overflow: hidden;
background: var(--bd-bg); background: var(--bd-bg);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
@@ -309,8 +359,8 @@
} }
.branch-display__ad-caption { .branch-display__ad-caption {
margin: 0.35rem 0 0; margin: 0.35em 0 0;
font-size: 0.8rem; font-size: 0.8em;
color: var(--bd-muted-strong); color: var(--bd-muted-strong);
text-align: center; text-align: center;
flex-shrink: 0; flex-shrink: 0;
@@ -320,7 +370,7 @@
} }
.branch-display__section--gold .branch-display__waiting-header { .branch-display__section--gold .branch-display__waiting-header {
margin-bottom: 0.35rem; margin-bottom: 0.35em;
} }
.branch-display__gold-table-wrap { .branch-display__gold-table-wrap {
@@ -328,7 +378,7 @@
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
border-radius: 0.5rem; border-radius: 0.5em;
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
background: var(--bd-surface); background: var(--bd-surface);
scrollbar-width: none; scrollbar-width: none;
@@ -341,19 +391,19 @@
.branch-display__gold-table { .branch-display__gold-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.9rem; font-size: 1.25em;
} }
.branch-display__gold-table th, .branch-display__gold-table th,
.branch-display__gold-table td { .branch-display__gold-table td {
padding: 0.45rem 0.65rem; padding: 0.55em 0.75em;
text-align: left; text-align: left;
border-bottom: 1px solid var(--bd-border); border-bottom: 1px solid var(--bd-border);
line-height: 1.3; line-height: 1.3;
} }
.branch-display__gold-table th { .branch-display__gold-table th {
font-size: 0.7rem; font-size: 0.72em;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
color: var(--bd-accent); color: var(--bd-accent);
@@ -3,11 +3,16 @@ import { useParams } from 'react-router-dom';
import { fetchData } from '../../fetching/Fetch.js'; import { fetchData } from '../../fetching/Fetch.js';
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js'; import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
import { import {
hasNewServingCall, getNewServingCalls,
playQueueCallSound, playQueueCallSound,
servingSnapshot, servingSnapshot,
unlockQueueCallSound, unlockQueueCallSound,
} from '../../utils/queueCallSound.js'; } from '../../utils/queueCallSound.js';
import {
announceQueueCalls,
cancelQueueSpeech,
unlockQueueSpeech,
} from '../../utils/queueCallSpeech.js';
import AdCarousel from './AdCarousel.jsx'; import AdCarousel from './AdCarousel.jsx';
import './BranchDisplayPage.css'; import './BranchDisplayPage.css';
@@ -172,14 +177,38 @@ export default function BranchDisplayPage() {
useEffect(() => { useEffect(() => {
const next = servingSnapshot(tickets); const next = servingSnapshot(tickets);
const previous = previousServingRef.current; const previous = previousServingRef.current;
const newCalls = getNewServingCalls(previous, tickets);
if (hasNewServingCall(previous, next)) { if (newCalls.length > 0) {
playQueueCallSound(); playQueueCallSound({
onEnded: () => announceQueueCalls(newCalls),
});
} }
previousServingRef.current = next; previousServingRef.current = next;
}, [tickets]); }, [tickets]);
useEffect(() => {
if (!window.speechSynthesis) return undefined;
const loadVoices = () => {
window.speechSynthesis.getVoices();
};
loadVoices();
window.speechSynthesis.addEventListener('voiceschanged', loadVoices);
return () => {
window.speechSynthesis.removeEventListener('voiceschanged', loadVoices);
cancelQueueSpeech();
};
}, []);
const unlockAnnouncements = useCallback(() => {
unlockQueueCallSound();
unlockQueueSpeech();
}, []);
const waiting = useMemo( const waiting = useMemo(
() => tickets.filter((ticket) => ticket.station == null), () => tickets.filter((ticket) => ticket.station == null),
[tickets] [tickets]
@@ -191,7 +220,7 @@ export default function BranchDisplayPage() {
); );
return ( return (
<div className="branch-display" onClick={unlockQueueCallSound}> <div className="branch-display" onClick={unlockAnnouncements}>
<header className="branch-display__header"> <header className="branch-display__header">
<div className="branch-display__brand"> <div className="branch-display__brand">
<img <img
@@ -223,9 +252,6 @@ export default function BranchDisplayPage() {
key={station.id} key={station.id}
className="branch-display__station-card" className="branch-display__station-card"
> >
<p className="branch-display__station-name">
{station.name}
</p>
<p <p
className={ className={
ticket ticket
@@ -235,8 +261,11 @@ export default function BranchDisplayPage() {
> >
{ticket ? ticket.number : '—'} {ticket ? ticket.number : '—'}
</p> </p>
<p className="branch-display__station-name">
{station.name}
</p>
<p className="branch-display__station-service"> <p className="branch-display__station-service">
{ticket?.service?.name ?? 'Waiting for next'} {ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'}
</p> </p>
</article> </article>
); );
+49 -8
View File
@@ -45,20 +45,35 @@ export function unlockQueueCallSound() {
/** /**
* Play the queue-call notification chime. * Play the queue-call notification chime.
* Safe to call repeatedly; restarts from the beginning each time. * Safe to call repeatedly; restarts from the beginning each time.
* @param {{ onEnded?: () => void }} [options]
*/ */
export function playQueueCallSound() { export function playQueueCallSound({ onEnded } = {}) {
let endedNotified = false;
const notifyEnded = () => {
if (endedNotified || !onEnded) return;
endedNotified = true;
onEnded();
};
try { try {
const audio = getCallAudio(); const audio = getCallAudio();
audio.muted = false; audio.muted = false;
audio.currentTime = 0; audio.currentTime = 0;
if (onEnded) {
audio.addEventListener('ended', notifyEnded, { once: true });
}
const playPromise = audio.play(); const playPromise = audio.play();
if (playPromise?.catch) { if (playPromise?.catch) {
playPromise.catch((error) => { playPromise.catch((error) => {
console.warn('Could not play queue call sound:', error); console.warn('Could not play queue call sound:', error);
notifyEnded();
}); });
} }
} catch (error) { } catch (error) {
console.warn('Could not play queue call sound:', error); console.warn('Could not play queue call sound:', error);
notifyEnded();
} }
} }
@@ -79,11 +94,37 @@ export function servingSnapshot(tickets) {
* Returns true if any station got a new/different ticket number vs the previous snapshot. * Returns true if any station got a new/different ticket number vs the previous snapshot.
*/ */
export function hasNewServingCall(previous, next) { export function hasNewServingCall(previous, next) {
if (!previous) return false; return getNewServingCalls(previous, next).length > 0;
for (const [stationId, number] of Object.entries(next)) { }
if (previous[stationId] !== number) {
return true; /**
} * Returns stations whose assigned ticket number changed since the previous snapshot.
} * Pass tickets when station names are needed for voice announcements.
return false; */
export function getNewServingCalls(previous, nextOrTickets) {
if (!previous) return [];
const tickets = Array.isArray(nextOrTickets)
? nextOrTickets
: Object.entries(nextOrTickets).map(([stationId, number]) => ({
station: { id: stationId },
number,
}));
const calls = [];
for (const ticket of tickets) {
if (ticket?.station?.id == null || ticket?.number == null) continue;
const stationId = String(ticket.station.id);
const number = String(ticket.number);
if (previous[stationId] === number) continue;
calls.push({
stationId,
ticketNumber: number,
stationName: ticket.station.name ?? `Stesen ${stationId}`,
});
}
return calls;
} }
@@ -0,0 +1,130 @@
const MALAY_LANG = 'ms-MY';
const MALAY_DIGITS = [
'kosong',
'satu',
'dua',
'tiga',
'empat',
'lima',
'enam',
'tujuh',
'lapan',
'sembilan',
];
let speechUnlocked = false;
let speechQueue = [];
let speaking = false;
function speechSynthesisAvailable() {
return typeof window !== 'undefined' && 'speechSynthesis' in window;
}
function loadVoices() {
if (!speechSynthesisAvailable()) return [];
return window.speechSynthesis.getVoices();
}
function pickMalayVoice() {
const voices = loadVoices();
return (
voices.find((voice) => voice.lang === MALAY_LANG) ||
voices.find((voice) => voice.lang.startsWith('ms')) ||
null
);
}
/**
* Spell ticket numbers digit-by-digit in Malay for clearer announcements.
*/
export function formatTicketNumberForSpeech(ticketNumber) {
return String(ticketNumber)
.trim()
.split('')
.map((character) => {
if (/\d/.test(character)) {
return MALAY_DIGITS[Number(character)];
}
return character;
})
.join(' ');
}
export function formatMalayAnnouncement(ticketNumber, stationName) {
const spokenNumber = formatTicketNumberForSpeech(ticketNumber);
return `Nombor giliran ${spokenNumber}, sila ke ${stationName}.`;
}
/**
* Call once after a user gesture so later speech is allowed on strict browsers.
*/
export function unlockQueueSpeech() {
if (speechUnlocked || !speechSynthesisAvailable()) return;
try {
loadVoices();
const utterance = new SpeechSynthesisUtterance('');
utterance.volume = 0;
utterance.lang = MALAY_LANG;
window.speechSynthesis.speak(utterance);
} catch {
// Ignore unlock failures; real announcements will still be attempted.
} finally {
speechUnlocked = true;
}
}
function processSpeechQueue() {
if (speaking || speechQueue.length === 0 || !speechSynthesisAvailable()) return;
speaking = true;
const text = speechQueue.shift();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = MALAY_LANG;
const voice = pickMalayVoice();
if (voice) {
utterance.voice = voice;
}
utterance.rate = 0.75;
const finish = () => {
speaking = false;
processSpeechQueue();
};
utterance.onend = finish;
utterance.onerror = () => {
console.warn('Could not speak queue announcement:', text);
finish();
};
window.speechSynthesis.speak(utterance);
}
/**
* Queue Malay voice announcements for one or more station calls.
* @param {{ ticketNumber: string, stationName: string }[]} calls
*/
export function announceQueueCalls(calls) {
if (!calls?.length || !speechSynthesisAvailable()) return;
unlockQueueSpeech();
for (const call of calls) {
speechQueue.push(
formatMalayAnnouncement(call.ticketNumber, call.stationName)
);
}
processSpeechQueue();
}
export function cancelQueueSpeech() {
if (!speechSynthesisAvailable()) return;
window.speechSynthesis.cancel();
speechQueue = [];
speaking = false;
}