Files
ismailmasseran 0420194541
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
Bugfix/testing (#4)
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

496 lines
15 KiB
TypeScript

"use client";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import {
Branch,
Service,
Tenant,
Ticket,
createTicket,
getBranchServices,
getBranches,
getTenant,
getTicketsForDevice,
} from "@/lib/api";
import { getDeviceToken } from "@/lib/device";
type Step = "tenant" | "branch" | "service" | "ticket";
type Props = {
initialTenantCode?: string;
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
): Ticket | null {
const scoped =
branchId != null
? tickets.filter((item) => item.branch.id === branchId)
: tickets;
if (scoped.length === 0) {
return null;
}
return [...scoped].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0];
}
export default function CustomerTicketFlow({
initialTenantCode,
initialBranchId,
}: Props) {
const fromQr =
initialTenantCode != null &&
initialTenantCode !== "" &&
initialBranchId != null &&
Number.isFinite(initialBranchId);
const [step, setStep] = useState<Step>(fromQr ? "service" : "tenant");
const [tenantCode, setTenantCode] = useState(
initialTenantCode?.trim().toUpperCase() ?? ""
);
const [tenant, setTenant] = useState<Tenant | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
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;
async function bootstrap() {
setLoading(true);
setError(null);
try {
const deviceToken = getDeviceToken();
if (deviceToken) {
const responses = await getTicketsForDevice(deviceToken);
const latest = pickLatestTicket(
responses.map((response) => response.ticket),
fromQr ? initialBranchId : undefined
);
if (latest) {
setTicket(latest);
setSelectedBranch({
id: latest.branch.id,
name: latest.branch.name,
tellerStations: latest.branch.tellerStations ?? [],
});
setStep("ticket");
setLoading(false);
return;
}
}
} catch {
// No saved ticket — continue into the normal flow.
}
if (!fromQr) {
setLoading(false);
return;
}
const code = initialTenantCode!.trim().toUpperCase();
const branchId = initialBranchId!;
try {
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
const branch = branchData.find((item) => item.id === branchId);
if (!branch) {
setError(
"This branch was not found. Scan the QR again or enter a company code."
);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
return;
}
const branchServices = await getBranchServices(code, branch.id);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setSelectedBranch(branch);
setServices(branchServices);
setStep("service");
} catch {
setError(
"Could not open this branch link. Scan the QR again or enter a company code."
);
setStep("tenant");
} finally {
setLoading(false);
}
}
void bootstrap();
}, [fromQr, initialTenantCode, initialBranchId]);
async function handleTenantSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = tenantCode.trim().toUpperCase();
if (!code) {
setError("Enter a company code.");
return;
}
setLoading(true);
setError(null);
try {
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
} catch {
setError("Could not find that company. Check the code and try again.");
} finally {
setLoading(false);
}
}
async function handleSelectBranch(branch: Branch) {
if (!tenant) return;
setLoading(true);
setError(null);
setSelectedBranch(branch);
try {
const branchServices = await getBranchServices(tenant.code, branch.id);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not load services for this branch.");
} finally {
setLoading(false);
}
}
async function handleSelectService(service: Service) {
if (!selectedBranch) return;
setLoading(true);
setError(null);
try {
const response = await createTicket({
branchId: selectedBranch.id,
serviceId: service.id,
deviceToken: getDeviceToken(),
});
setTicket(response.ticket);
setSelectedBranch(response.ticket.branch);
setStep("ticket");
} catch {
setError(
"Could not get a ticket number. Make sure this branch has services assigned."
);
} finally {
setLoading(false);
}
}
async function resetFlow() {
setTicket(null);
setError(null);
if (fromQr && initialTenantCode && initialBranchId != null) {
setLoading(true);
try {
const code = initialTenantCode.trim().toUpperCase();
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
const branch = branchData.find((item) => item.id === initialBranchId);
if (!branch) {
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
return;
}
const branchServices = await getBranchServices(code, branch.id);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setSelectedBranch(branch);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not reload services. Try scanning the QR again.");
setStep("tenant");
} finally {
setLoading(false);
}
return;
}
if (selectedBranch && tenant) {
setLoading(true);
try {
const branchServices = await getBranchServices(
tenant.code,
selectedBranch.id
);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not load services for this branch.");
setStep("branch");
} finally {
setLoading(false);
}
return;
}
setStep(tenant ? "branch" : "tenant");
}
function startOver() {
setStep("tenant");
setTenantCode("");
setTenant(null);
setBranches([]);
setSelectedBranch(null);
setServices([]);
setTicket(null);
setError(null);
}
const welcomeCopy = fromQr
? "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">
<p className="text-sm tracking-wide text-zinc-500 uppercase">
Sistem Nombor Giliran
</p>
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900">
{tenant?.name ?? "Dapatkan tiket"}
</h1>
<p className="text-base text-zinc-600">
{tenant?.welcomeMessage ?? welcomeCopy}
</p>
</header>
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</p>
) : null}
{fromQr && loading && step === "service" && !selectedBranch ? (
<p className="text-sm text-zinc-600">Loading perkhidmatan branch</p>
) : null}
{step === "tenant" ? (
<form onSubmit={handleTenantSubmit} className="space-y-4">
<label className="block space-y-2">
<span className="text-sm font-medium text-zinc-700">
Kod syarikat
</span>
<input
value={tenantCode}
onChange={(event) => setTenantCode(event.target.value)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 outline-none focus:border-zinc-900"
placeholder="Kod syarikat"
autoComplete="off"
disabled={loading}
/>
</label>
<button
type="submit"
disabled={loading || !tenantCode.trim()}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white disabled:opacity-60"
>
{loading ? "Loading…" : "Lanjutkan"}
</button>
</form>
) : null}
{step === "branch" ? (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium text-zinc-900">Choose a branch</h2>
<button
type="button"
onClick={startOver}
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Change company
</button>
</div>
{branches.length === 0 ? (
<p className="text-sm text-zinc-600">No branches available.</p>
) : (
<ul className="space-y-2">
{branches.map((branch) => (
<li key={branch.id}>
<button
type="button"
disabled={loading}
onClick={() => handleSelectBranch(branch)}
className="flex w-full items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-3 text-left text-zinc-900 transition hover:border-zinc-400 disabled:opacity-60"
>
<span>{branch.name}</span>
<span className="text-zinc-400"></span>
</button>
</li>
))}
</ul>
)}
</section>
) : null}
{step === "service" && selectedBranch ? (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium text-zinc-900">
Perkhidmatan di Cawangan {selectedBranch.name}
</h2>
{fromQr ? (
<Link
href="/"
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Masukkan kod perkhidmatan
</Link>
) : (
<button
type="button"
onClick={() => {
setStep("branch");
setSelectedBranch(null);
setServices([]);
setError(null);
}}
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Kembali
</button>
)}
</div>
{services.length === 0 ? (
<p className="text-sm text-zinc-600">
Tiada perkhidmatan yang ditugaskan kepada branch ini. Hubungkan
perkhidmatan melalui grup branch dalam aplikasi admin, kemudian
cuba lagi.
</p>
) : (
<ul className="space-y-2">
{services.map((service) => (
<li key={service.id}>
<button
type="button"
disabled={loading}
onClick={() => handleSelectService(service)}
className="flex w-full items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-3 text-left text-zinc-900 transition hover:border-zinc-400 disabled:opacity-60"
>
<span>{service.name}</span>
<span className="text-sm text-zinc-500">
{loading ? "…" : "Dapatkan nombor"}
</span>
</button>
</li>
))}
</ul>
)}
</section>
) : null}
{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 giliran anda
</p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number}
</p>
<div className="space-y-1 text-sm text-zinc-600">
<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}
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"
}
>
{canGetAnotherTicket
? "Dapatkan nombor giliran lain"
: `Tunggu ${cooldownSeconds}s…`}
</button>
</section>
) : null}
</main>
);
}