DONE: queue management for customer side to get queue number, add logo to teller display, advertisement module
@@ -30,9 +30,6 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Customer Queue",
|
||||
description: "Take a queue ticket for your branch and service",
|
||||
title: "MyKOPKB QMS-Customer App",
|
||||
description: "MyKOPKB QMS-Customer App",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
|
||||
import { decodeBranchQrToken } from "@/lib/branchToken";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function BranchQrPage({ params }: PageProps) {
|
||||
const { token } = await params;
|
||||
const payload = decodeBranchQrToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
|
||||
<p className="text-zinc-600">
|
||||
This branch QR link is not valid. Scan again or enter a company code.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
|
||||
>
|
||||
Enter company code
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<CustomerTicketFlow
|
||||
initialTenantCode={payload.tenantCode}
|
||||
initialBranchId={payload.branchId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
tenantCode: string;
|
||||
branchId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function BranchTicketPage({ params }: PageProps) {
|
||||
const { tenantCode, branchId: branchIdParam } = await params;
|
||||
const branchId = Number(branchIdParam);
|
||||
|
||||
if (!Number.isFinite(branchId)) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
|
||||
<p className="text-zinc-600">
|
||||
This branch QR link is not valid. Scan again or enter a company code.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
|
||||
>
|
||||
Enter company code
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<CustomerTicketFlow
|
||||
initialTenantCode={tenantCode}
|
||||
initialBranchId={branchId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Branch,
|
||||
Service,
|
||||
@@ -10,21 +11,134 @@ import {
|
||||
getBranchServices,
|
||||
getBranches,
|
||||
getTenant,
|
||||
getTicketsForDevice,
|
||||
} from "@/lib/api";
|
||||
import { getDeviceToken } from "@/lib/device";
|
||||
|
||||
type Step = "tenant" | "branch" | "service" | "ticket";
|
||||
|
||||
export default function CustomerTicketFlow() {
|
||||
const [step, setStep] = useState<Step>("tenant");
|
||||
const [tenantCode, setTenantCode] = useState("DFLT");
|
||||
type Props = {
|
||||
initialTenantCode?: string;
|
||||
initialBranchId?: number;
|
||||
};
|
||||
|
||||
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(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const bootstrapped = useRef(false);
|
||||
|
||||
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();
|
||||
@@ -84,6 +198,7 @@ export default function CustomerTicketFlow() {
|
||||
deviceToken: getDeviceToken(),
|
||||
});
|
||||
setTicket(response.ticket);
|
||||
setSelectedBranch(response.ticket.branch);
|
||||
setStep("ticket");
|
||||
} catch {
|
||||
setError(
|
||||
@@ -94,16 +209,66 @@ export default function CustomerTicketFlow() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetFlow() {
|
||||
setStep(tenant ? "branch" : "tenant");
|
||||
setSelectedBranch(null);
|
||||
setServices([]);
|
||||
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);
|
||||
@@ -112,18 +277,21 @@ export default function CustomerTicketFlow() {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const welcomeCopy = fromQr
|
||||
? "Pilih perkhidmatan untuk mendapatkan nombor."
|
||||
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
|
||||
|
||||
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">
|
||||
Queue Management
|
||||
Sistem Nombor Giliran
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900">
|
||||
{tenant?.name ?? "Get a ticket"}
|
||||
{tenant?.name ?? "Dapatkan tiket"}
|
||||
</h1>
|
||||
<p className="text-base text-zinc-600">
|
||||
{tenant?.welcomeMessage ??
|
||||
"Enter your company code, pick a branch and service, then take a number."}
|
||||
{tenant?.welcomeMessage ?? welcomeCopy}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -133,27 +301,31 @@ export default function CustomerTicketFlow() {
|
||||
</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">
|
||||
Company code
|
||||
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="e.g. DFLT"
|
||||
placeholder="Kod syarikat"
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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…" : "Continue"}
|
||||
{loading ? "Loading…" : "Lanjutkan"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
@@ -196,25 +368,35 @@ export default function CustomerTicketFlow() {
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-medium text-zinc-900">
|
||||
Service at {selectedBranch.name}
|
||||
Perkhidmatan di Cawangan {selectedBranch.name}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("branch");
|
||||
setSelectedBranch(null);
|
||||
setServices([]);
|
||||
setError(null);
|
||||
}}
|
||||
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{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">
|
||||
No services are assigned to this branch yet. Link services via a
|
||||
branch group in the admin app, then try again.
|
||||
Tiada perkhidmatan yang ditugaskan kepada branch ini. Hubungkan
|
||||
perkhidmatan melalui grup branch dalam aplikasi admin, kemudian
|
||||
cuba lagi.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
@@ -228,7 +410,7 @@ export default function CustomerTicketFlow() {
|
||||
>
|
||||
<span>{service.name}</span>
|
||||
<span className="text-sm text-zinc-500">
|
||||
{loading ? "…" : "Get number"}
|
||||
{loading ? "…" : "Dapatkan nombor"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -241,7 +423,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">
|
||||
Your ticket number
|
||||
Nombor tiket anda
|
||||
</p>
|
||||
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
|
||||
{ticket.number}
|
||||
@@ -255,7 +437,7 @@ export default function CustomerTicketFlow() {
|
||||
onClick={resetFlow}
|
||||
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
|
||||
>
|
||||
Get another ticket
|
||||
Dapatkan tiket lain
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -80,3 +80,9 @@ export function createTicket(input: {
|
||||
export function getTicketById(ticketId: number | string) {
|
||||
return request<Ticket>(`/api/v1/tickets/${ticketId}`);
|
||||
}
|
||||
|
||||
export function getTicketsForDevice(deviceToken: string) {
|
||||
return request<TicketResponse[]>(
|
||||
`/api/v1/tickets/devices/${encodeURIComponent(deviceToken)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
const DEFAULT_QR_SECRET = "qms-branch-qr-v1";
|
||||
|
||||
function getQrSecret() {
|
||||
return process.env.QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
|
||||
}
|
||||
|
||||
function toBase64Url(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function fromBase64Url(token: string) {
|
||||
const padded = token.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLength = (4 - (padded.length % 4)) % 4;
|
||||
const base64 = padded + "=".repeat(padLength);
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function xorBytes(bytes: Uint8Array, secret: string) {
|
||||
const key = new TextEncoder().encode(secret);
|
||||
return bytes.map((byte, index) => byte ^ key[index % key.length]);
|
||||
}
|
||||
|
||||
export type BranchQrPayload = {
|
||||
tenantCode: string;
|
||||
branchId: number;
|
||||
};
|
||||
|
||||
export function encodeBranchQrToken(
|
||||
tenantCode: string,
|
||||
branchId: number,
|
||||
secret = getQrSecret()
|
||||
) {
|
||||
const plain = `v1:${tenantCode.trim().toUpperCase()}:${Number(branchId)}`;
|
||||
const plainBytes = new TextEncoder().encode(plain);
|
||||
return toBase64Url(xorBytes(plainBytes, secret));
|
||||
}
|
||||
|
||||
export function decodeBranchQrToken(
|
||||
token: string,
|
||||
secret = getQrSecret()
|
||||
): BranchQrPayload | null {
|
||||
try {
|
||||
const plain = new TextDecoder().decode(
|
||||
xorBytes(fromBase64Url(token), secret)
|
||||
);
|
||||
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
|
||||
if (!match) return null;
|
||||
return {
|
||||
tenantCode: match[1],
|
||||
branchId: Number(match[2]),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export const SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
export const SERVER_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
export const DEVICE_TOKEN_KEY = "qms_device_token";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||