Bugfix/testing #4

Merged
ismailmasseran merged 2 commits from bugfix/testing into main 2026-07-30 16:22:51 +08:00
11 changed files with 206 additions and 166 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ name: Build Docker Image
on: on:
push: push:
branches: # branches:
- main # - main
tags: tags:
- "v*" - "v*"
+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"
@@ -76,22 +76,41 @@ export default function AdCarousel({ tenantCode }) {
const video = videoRef.current; const video = videoRef.current;
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined; if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
let removeUnmuteListener = () => {};
const onEnded = () => goNext(); const onEnded = () => goNext();
const scheduleFallbackAdvance = () => {
clearTimer();
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
timerRef.current = setTimeout(goNext, durationMs);
};
video.addEventListener('ended', onEnded); video.addEventListener('ended', onEnded);
video.muted = true; video.muted = false;
video.playsInline = true; video.playsInline = true;
const playPromise = video.play(); const playPromise = video.play();
if (playPromise?.catch) { if (playPromise?.catch) {
playPromise.catch(() => { playPromise.catch(() => {
// Autoplay blocked — advance after durationSeconds fallback // Unmuted autoplay blocked — keep video playing muted, then unmute on gesture.
clearTimer(); video.muted = true;
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000; const mutedPlay = video.play();
timerRef.current = setTimeout(goNext, durationMs); if (mutedPlay?.catch) {
mutedPlay.catch(scheduleFallbackAdvance);
}
const unmute = () => {
video.muted = false;
video.play()?.catch(() => {});
};
window.addEventListener('pointerdown', unmute, { once: true });
removeUnmuteListener = () => window.removeEventListener('pointerdown', unmute);
}); });
} }
return () => { return () => {
video.removeEventListener('ended', onEnded); video.removeEventListener('ended', onEnded);
removeUnmuteListener();
}; };
}, [currentAd, goNext, clearTimer]); }, [currentAd, goNext, clearTimer]);
@@ -127,7 +146,6 @@ export default function AdCarousel({ tenantCode }) {
ref={videoRef} ref={videoRef}
className="branch-display__ad-media" className="branch-display__ad-media"
src={src} src={src}
muted
playsInline playsInline
autoPlay autoPlay
/> />
@@ -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,54 +32,37 @@
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;
} }
.branch-display__sound-enable {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
z-index: 20;
border: 1px solid var(--bd-accent-deep);
background: linear-gradient(180deg, var(--bd-accent-bright) 0%, var(--bd-accent) 100%);
color: #1a2e14;
border-radius: 999px;
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 12px rgba(240, 208, 96, 0.35);
}
.branch-display__header { .branch-display__header {
margin: 0; margin: 0;
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);
@@ -84,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);
@@ -93,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;
} }
@@ -111,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;
@@ -127,7 +115,7 @@
.branch-display__section--gold { .branch-display__section--gold {
flex: none; flex: none;
min-height: 16rem; min-height: 16em;
} }
} }
@@ -136,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;
} }
@@ -145,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 {
@@ -158,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;
@@ -171,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.3em;
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);
@@ -189,18 +189,38 @@
.branch-display__station-name { .branch-display__station-name {
margin: 0; margin: 0;
font-size: 0.8rem; font-size: 1.1em;
color: var(--bd-muted); color: var(--bd-muted);
} }
.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.15em;
}
.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 {
@@ -210,7 +230,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;
@@ -221,8 +241,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;
} }
@@ -232,14 +252,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 {
@@ -248,7 +268,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;
@@ -258,24 +278,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);
} }
@@ -283,8 +303,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);
@@ -297,8 +317,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;
} }
@@ -311,7 +331,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);
@@ -326,8 +346,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;
@@ -337,7 +357,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 {
@@ -345,7 +365,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;
@@ -358,19 +378,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);
@@ -29,7 +29,6 @@ export default function BranchDisplayPage() {
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null); const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
const [goldError, setGoldError] = useState(null); const [goldError, setGoldError] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [soundReady, setSoundReady] = useState(false);
const goldTableWrapRef = useRef(null); const goldTableWrapRef = useRef(null);
const previousServingRef = useRef(null); const previousServingRef = useRef(null);
@@ -192,29 +191,7 @@ export default function BranchDisplayPage() {
); );
return ( return (
<div <div className="branch-display" onClick={unlockQueueCallSound}>
className="branch-display"
onClick={() => {
if (!soundReady) {
unlockQueueCallSound();
setSoundReady(true);
}
}}
>
{!soundReady ? (
<button
type="button"
className="branch-display__sound-enable"
onClick={(event) => {
event.stopPropagation();
unlockQueueCallSound();
setSoundReady(true);
}}
>
Enable call sound
</button>
) : null}
<header className="branch-display__header"> <header className="branch-display__header">
<div className="branch-display__brand"> <div className="branch-display__brand">
<img <img
@@ -259,7 +236,7 @@ export default function BranchDisplayPage() {
{ticket ? ticket.number : '—'} {ticket ? ticket.number : '—'}
</p> </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>
); );