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:
push:
branches:
- main
# branches:
# - main
tags:
- "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
restart: unless-stopped
ports:
- "${MYSQL_HOST_PORT:-3307}:3306"
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
@@ -27,37 +27,8 @@ services:
networks:
- 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:
qms-mysql-data:
qms-uploads:
networks:
qms-net:
@@ -33,13 +33,14 @@ public class DefaultAdminService implements AdminService {
private final RoleService roleService;
private final PasswordEncoder passwordEncoder;
private final AuthService authService;
public DefaultAdminService(final UserRepository userRepository,
final UserService userService,
final TwoFactorService twoFactorService,
final TenantService tenantService,
final RoleService roleService,
final PasswordEncoder passwordEncoder,
final AuthService authService) {
final UserService userService,
final TwoFactorService twoFactorService,
final TenantService tenantService,
final RoleService roleService,
final PasswordEncoder passwordEncoder,
final AuthService authService) {
this.userRepository = userRepository;
this.userService = userService;
this.twoFactorService = twoFactorService;
@@ -50,7 +51,7 @@ public class DefaultAdminService implements AdminService {
}
@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));
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.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@@ -30,7 +31,8 @@ public class AdminController {
@PostMapping("/{code}")
@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;
try {
roleName = RoleName.valueOf(request.roleName);
@@ -39,7 +41,7 @@ public class AdminController {
return ResponseEntity.badRequest().build();
}
if(this.authService.canOnlyCRUDUser(roleName)){
if (this.authService.canOnlyCRUDUser(roleName)) {
logger.warn("Only super admin can read admins");
return ResponseEntity.badRequest().build();
}
@@ -52,8 +54,7 @@ public class AdminController {
return ResponseEntity.ok().body(
this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
.map(UserDto::fromEntity)
.collect(Collectors.toList())
);
.collect(Collectors.toList()));
} catch (EntityNotFoundException e) {
return ResponseEntity.badRequest().build();
}
@@ -61,7 +62,8 @@ public class AdminController {
@PostMapping("/{code}/user")
@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;
try {
roleName = RoleName.valueOf(request.roleName);
@@ -70,7 +72,7 @@ public class AdminController {
return ResponseEntity.badRequest().build();
}
if(this.authService.canOnlyCRUDUser(roleName)){
if (this.authService.canOnlyCRUDUser(roleName)) {
logger.warn("Only super admin can add admin");
return ResponseEntity.badRequest().build();
}
@@ -95,7 +97,8 @@ public class AdminController {
@PutMapping("/{code}/user/{userId}")
@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)) {
logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build();
@@ -112,7 +115,8 @@ public class AdminController {
@DeleteMapping("/{code}/user/{userId}")
@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)) {
logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build();
@@ -130,6 +134,6 @@ public class AdminController {
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;
public AdvertisementController(final AdvertisementService advertisementService,
final AuthService authService) {
final AuthService authService) {
this.advertisementService = advertisementService;
this.authService = authService;
}
@GetMapping("/media/{adId}")
public ResponseEntity streamMedia(@PathVariable final long adId) {
public ResponseEntity<Resource> streamMedia(@PathVariable final long adId) {
try {
final Advertisement advertisement = this.advertisementService.findById(adId);
final Resource resource = this.advertisementService.loadMedia(adId);
@@ -64,11 +64,11 @@ public class AdvertisementController {
@PostMapping("/{tenantCode}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity createAdvertisement(@PathVariable final String tenantCode,
@RequestParam("file") final MultipartFile file,
@RequestParam(value = "title", required = false) final String title,
@RequestParam(value = "durationSeconds", required = false) final Integer durationSeconds,
@RequestParam(value = "sortOrder", required = false) final Integer sortOrder,
@RequestParam(value = "active", required = false) final Boolean active) {
@RequestParam("file") final MultipartFile file,
@RequestParam(value = "title", required = false) final String title,
@RequestParam(value = "durationSeconds", required = false) final Integer durationSeconds,
@RequestParam(value = "sortOrder", required = false) final Integer sortOrder,
@RequestParam(value = "active", required = false) final Boolean active) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
@@ -80,8 +80,7 @@ public class AdvertisementController {
title,
durationSeconds,
sortOrder,
active
);
active);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created));
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -108,8 +107,8 @@ public class AdvertisementController {
@PutMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity updateAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId,
@RequestBody final AdvertisementUpdateRequest request) {
@PathVariable final long adId,
@RequestBody final AdvertisementUpdateRequest request) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
@@ -121,8 +120,7 @@ public class AdvertisementController {
request.title(),
request.durationSeconds(),
request.sortOrder(),
request.active()
);
request.active());
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated));
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -132,7 +130,7 @@ public class AdvertisementController {
@DeleteMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity deleteAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId) {
@PathVariable final long adId) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
@@ -146,8 +144,8 @@ public class AdvertisementController {
}
public record AdvertisementUpdateRequest(String title,
Integer durationSeconds,
Integer sortOrder,
Boolean active) {
Integer durationSeconds,
Integer sortOrder,
Boolean active) {
}
}
@@ -22,6 +22,9 @@ type Props = {
initialBranchId?: number;
};
/** Prevents spam of "Dapatkan tiket lain" right after getting a number. */
const ANOTHER_TICKET_COOLDOWN_MS = 10_000;
function pickLatestTicket(
tickets: Ticket[],
branchId?: number
@@ -61,8 +64,27 @@ export default function CustomerTicketFlow({
const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cooldownLeftMs, setCooldownLeftMs] = useState(0);
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(() => {
if (bootstrapped.current) return;
bootstrapped.current = true;
@@ -281,6 +303,9 @@ export default function CustomerTicketFlow({
? "Pilih perkhidmatan untuk mendapatkan nombor."
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
const canGetAnotherTicket = cooldownLeftMs <= 0;
const cooldownSeconds = Math.ceil(cooldownLeftMs / 1000);
return (
<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">
@@ -423,7 +448,7 @@ export default function CustomerTicketFlow({
{step === "ticket" && ticket ? (
<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">
Nombor tiket anda
Nombor giliran anda
</p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number}
@@ -432,12 +457,36 @@ export default function CustomerTicketFlow({
<p>{ticket.service.name}</p>
<p>{ticket.branch.name}</p>
</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
type="button"
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>
</section>
) : null}
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -76,22 +76,41 @@ export default function AdCarousel({ tenantCode }) {
const video = videoRef.current;
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
let removeUnmuteListener = () => {};
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.muted = true;
video.muted = false;
video.playsInline = true;
const playPromise = video.play();
if (playPromise?.catch) {
playPromise.catch(() => {
// Autoplay blocked — advance after durationSeconds fallback
clearTimer();
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
timerRef.current = setTimeout(goNext, durationMs);
// Unmuted autoplay blocked — keep video playing muted, then unmute on gesture.
video.muted = true;
const mutedPlay = video.play();
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 () => {
video.removeEventListener('ended', onEnded);
removeUnmuteListener();
};
}, [currentAd, goNext, clearTimer]);
@@ -127,7 +146,6 @@ export default function AdCarousel({ tenantCode }) {
ref={videoRef}
className="branch-display__ad-media"
src={src}
muted
playsInline
autoPlay
/>
@@ -15,11 +15,15 @@
--bd-error-bg: #5c1a1a;
--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;
height: 100vh;
height: 100dvh;
width: 100%;
box-sizing: border-box;
padding: 1rem 1.5rem 1rem;
padding: 0.75em 1em;
background:
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%),
@@ -28,54 +32,37 @@
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.75rem;
gap: 0.6em;
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 {
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
gap: 1em;
}
.branch-display__brand {
display: flex;
align-items: center;
gap: 0.85rem;
gap: 0.85em;
min-width: 0;
}
.branch-display__logo {
height: clamp(2.5rem, 4.5vw, 3.5rem);
height: clamp(2em, 1.8em + 1vw, 4em);
width: auto;
flex-shrink: 0;
border-radius: 0.35rem;
border-radius: 0.35em;
object-fit: contain;
background: #fff;
}
.branch-display__eyebrow {
margin: 0;
font-size: 0.75rem;
font-size: 0.75em;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bd-accent);
@@ -84,7 +71,7 @@
.branch-display__title {
margin: 0;
font-size: clamp(1.4rem, 2.2vw, 2rem);
font-size: clamp(1.25em, 2vw, 1.75em);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--bd-text);
@@ -93,17 +80,17 @@
.branch-display__error {
margin: 0;
padding: 0.5rem 0.75rem;
border-radius: 0.4rem;
padding: 0.5em 0.75em;
border-radius: 0.4em;
background: var(--bd-error-bg);
color: var(--bd-error-text);
font-size: 0.9rem;
font-size: 0.9em;
}
.branch-display__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
gap: 0.85rem;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 0.85em;
min-height: 0;
overflow: hidden;
}
@@ -111,6 +98,7 @@
@media (max-width: 960px) {
.branch-display {
height: auto;
min-height: 100dvh;
min-height: 100vh;
overflow: auto;
grid-template-rows: auto;
@@ -127,7 +115,7 @@
.branch-display__section--gold {
flex: none;
min-height: 16rem;
min-height: 16em;
}
}
@@ -136,7 +124,7 @@
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: 0.75em;
overflow: hidden;
}
@@ -145,12 +133,23 @@
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 {
flex: 0 1 auto;
max-height: 28%;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
position: relative;
z-index: 1;
background: transparent;
}
.branch-display__section--gold {
@@ -158,11 +157,12 @@
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.branch-display__section-title {
margin: 0 0 0.45rem;
font-size: 0.85rem;
margin: 0 0 0.45em;
font-size: 1.05em;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -171,16 +171,16 @@
.branch-display__stations {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.55rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10em), 1fr));
gap: 0.7em;
}
.branch-display__station-card {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.65rem 0.5rem;
border-radius: 0.5rem;
gap: 0.3em;
padding: 0.9em 0.65em;
border-radius: 0.5em;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
@@ -189,18 +189,38 @@
.branch-display__station-name {
margin: 0;
font-size: 0.8rem;
font-size: 1.1em;
color: var(--bd-muted);
}
.branch-display__ticket-number {
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;
line-height: 1;
letter-spacing: -0.03em;
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 {
@@ -210,7 +230,7 @@
.branch-display__station-service {
margin: 0;
font-size: 0.75rem;
font-size: 1em;
color: var(--bd-muted-strong);
white-space: nowrap;
overflow: hidden;
@@ -221,8 +241,8 @@
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
gap: 0.75em;
margin-bottom: 0.4em;
flex-shrink: 0;
}
@@ -232,14 +252,14 @@
.branch-display__waiting-count {
margin: 0;
font-size: 0.85rem;
font-size: 1.05em;
color: var(--bd-accent);
}
.branch-display__empty {
margin: 0;
color: var(--bd-muted);
font-size: 0.9rem;
font-size: 1.05em;
}
.branch-display__waiting-list {
@@ -248,7 +268,7 @@
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
gap: 0.45em;
flex: 1;
min-height: 0;
overflow: auto;
@@ -258,24 +278,24 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.7rem;
border-radius: 0.4rem;
gap: 0.75em;
padding: 0.55em 0.85em;
border-radius: 0.4em;
background: var(--bd-surface);
border: 1px solid var(--bd-border);
border-left: 3px solid var(--bd-green);
border-left: 0.2em solid var(--bd-green);
flex-shrink: 0;
}
.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;
letter-spacing: -0.02em;
color: var(--bd-accent);
}
.branch-display__waiting-service {
font-size: 0.85rem;
font-size: 0.85em;
color: var(--bd-muted-strong);
}
@@ -283,8 +303,8 @@
min-width: 0;
min-height: 0;
height: 100%;
padding: 0.65rem;
border-radius: 0.6rem;
padding: 0.65em;
border-radius: 0.6em;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
@@ -297,8 +317,8 @@
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
gap: 0.75em;
margin-bottom: 0.4em;
flex-shrink: 0;
}
@@ -311,7 +331,7 @@
flex: 1;
min-height: 0;
width: 100%;
border-radius: 0.4rem;
border-radius: 0.4em;
overflow: hidden;
background: var(--bd-bg);
border: 1px solid var(--bd-border);
@@ -326,8 +346,8 @@
}
.branch-display__ad-caption {
margin: 0.35rem 0 0;
font-size: 0.8rem;
margin: 0.35em 0 0;
font-size: 0.8em;
color: var(--bd-muted-strong);
text-align: center;
flex-shrink: 0;
@@ -337,7 +357,7 @@
}
.branch-display__section--gold .branch-display__waiting-header {
margin-bottom: 0.35rem;
margin-bottom: 0.35em;
}
.branch-display__gold-table-wrap {
@@ -345,7 +365,7 @@
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-radius: 0.5rem;
border-radius: 0.5em;
border: 1px solid var(--bd-border);
background: var(--bd-surface);
scrollbar-width: none;
@@ -358,19 +378,19 @@
.branch-display__gold-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
font-size: 1.25em;
}
.branch-display__gold-table th,
.branch-display__gold-table td {
padding: 0.45rem 0.65rem;
padding: 0.55em 0.75em;
text-align: left;
border-bottom: 1px solid var(--bd-border);
line-height: 1.3;
}
.branch-display__gold-table th {
font-size: 0.7rem;
font-size: 0.72em;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--bd-accent);
@@ -29,7 +29,6 @@ export default function BranchDisplayPage() {
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
const [goldError, setGoldError] = useState(null);
const [error, setError] = useState(null);
const [soundReady, setSoundReady] = useState(false);
const goldTableWrapRef = useRef(null);
const previousServingRef = useRef(null);
@@ -192,29 +191,7 @@ export default function BranchDisplayPage() {
);
return (
<div
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}
<div className="branch-display" onClick={unlockQueueCallSound}>
<header className="branch-display__header">
<div className="branch-display__brand">
<img
@@ -259,7 +236,7 @@ export default function BranchDisplayPage() {
{ticket ? ticket.number : '—'}
</p>
<p className="branch-display__station-service">
{ticket?.service?.name ?? 'Waiting for next'}
{ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'}
</p>
</article>
);