"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { apiFetch, ApiError } from "@/lib/api"; import { isValidKenyanPhone } from "@/lib/phone"; import { AuthUser, PaymentMethod, Product } from "@/lib/types"; import { dismissRejected, flushQueue, generateClientUuid, queueCounts, queueSale, registerOfflineSync, salesFor, type QueuedSale, type QueueOwner, } from "@/lib/offlineQueue"; import { clearSession, loadSession, saveSession } from "@/lib/sessionCache"; import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol"; import { useToast } from "@/components/ui/Toast"; import ProductGrid from "@/components/terminal/ProductGrid"; import Receipt from "@/components/terminal/Receipt"; import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle"; import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay"; type ScreenState = | { kind: "idle" } | { kind: "receipt"; sale: QueuedSale } | { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null }; interface CartLine { productId: string; quantity: number; } export default function TerminalPage() { const router = useRouter(); const { show } = useToast(); const [me, setMe] = useState(null); const [mpesaEnabled, setMpesaEnabled] = useState(false); const [bootError, setBootError] = useState(null); const [online, setOnline] = useState(true); // navigator.onLine only knows about the phone's own connection; this // also goes false when the server could not be reached. const [serverReachable, setServerReachable] = useState(true); const [counts, setCounts] = useState({ pending: 0, rejected: 0 }); const [showQueue, setShowQueue] = useState(false); const [queueRows, setQueueRows] = useState([]); const [products, setProducts] = useState([]); const [productsLoading, setProductsLoading] = useState(true); const [cart, setCart] = useState([]); const [showCustomer, setShowCustomer] = useState(false); const [customerName, setCustomerName] = useState(""); const [customerPhone, setCustomerPhone] = useState(""); const [method, setMethod] = useState("CASH"); const [phoneError, setPhoneError] = useState(null); const [formError, setFormError] = useState(null); const [submitting, setSubmitting] = useState(false); const [screen, setScreen] = useState({ kind: "idle" }); const pollRef = useRef | null>(null); const owner: QueueOwner | null = me ? { id: me.id, businessId: me.businessId, branchId: me.branchId } : null; const productById = new Map(products.map((p) => [p._id, p])); const lines = cart .map((c) => ({ ...c, product: productById.get(c.productId) })) .filter((c): c is CartLine & { product: Product } => !!c.product); const total = lines.reduce((sum, l) => sum + l.quantity * l.product.unitPrice, 0); const refreshCounts = useCallback(async (userId: string) => { setCounts(await queueCounts(userId)); }, []); // Boot: confirm the session online, or fall back to the last confirmed // session on this device so a cashier can keep selling with no network. useEffect(() => { (async () => { const cached = loadSession(); let user: AuthUser; try { const result = await apiFetch<{ user: AuthUser; features?: { mpesa: boolean } }>("/api/auth/me"); if (result.user.role === "SUPER_ADMIN") { router.replace("/admin/dashboard"); return; } user = result.user; setMpesaEnabled(!!result.features?.mpesa); saveSession({ user, features: { mpesa: !!result.features?.mpesa } }); } catch (err) { // Signed out or blocked: go to login. Anything else (no network, // server unreachable) falls back to the saved session. if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { router.replace("/login"); return; } if (!cached) { setBootError("You're offline. Connect to the internet once to sign in on this phone."); return; } user = cached.user; setServerReachable(false); setMpesaEnabled(false); // M-Pesa needs a connection anyway show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info"); } setMe(user); await refreshCounts(user.id); try { const productsResult = await apiFetch<{ products: Product[] }>("/api/products"); setProducts(productsResult.products); saveSession({ user, products: productsResult.products }); } catch { const saved = loadSession(); if (saved?.user.id === user.id && saved.products.length > 0) { setProducts(saved.products); } else { show("Could not load products - check your connection", "error"); } } setProductsLoading(false); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Connectivity + background sync for this cashier's queue. useEffect(() => { if (!me) return; setOnline(navigator.onLine); const goOnline = () => { setOnline(true); show("Back online - syncing saved sales", "info"); }; const goOffline = () => { setOnline(false); show("You're offline - cash sales keep saving and sync automatically", "info"); }; window.addEventListener("online", goOnline); window.addEventListener("offline", goOffline); const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => { setCounts((c) => ({ ...c, pending: r.pending })); setServerReachable(!r.offline); if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success"); if (r.rejected > 0) { show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error"); refreshCounts(me.id); } }); return () => { window.removeEventListener("online", goOnline); window.removeEventListener("offline", goOffline); unregister(); if (pollRef.current) clearInterval(pollRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [me?.id]); function addProduct(productId: string) { setFormError(null); setCart((prev) => { const existing = prev.find((l) => l.productId === productId); if (existing) { return prev.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + 1) } : l)); } if (prev.length >= MAX_LINES_PER_SALE) return prev; return [...prev, { productId, quantity: 1 }]; }); } function changeQuantity(productId: string, delta: number) { setCart((prev) => prev .map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + delta) } : l)) .filter((l) => l.quantity > 0) ); } function resetForm() { setCart([]); setCustomerName(""); setCustomerPhone(""); setShowCustomer(false); setMethod("CASH"); setPhoneError(null); setFormError(null); setScreen({ kind: "idle" }); } function validate(requirePhone: boolean): boolean { if (lines.length === 0) { setFormError("Add at least one product."); return false; } if ((requirePhone || customerPhone.trim()) && !isValidKenyanPhone(customerPhone)) { setShowCustomer(true); setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX"); return false; } setPhoneError(null); setFormError(null); return true; } async function handleCashSale() { if (!me || !owner || !validate(false)) return; if (!me.branchId) { setFormError("Your account is not assigned to a branch."); return; } setSubmitting(true); // The IndexedDB write is the only step that must finish before we show // success: it is what keeps the sale if the phone dies before syncing. const sale = await queueSale({ clientUuid: generateClientUuid(), userId: me.id, businessId: me.businessId ?? "", branchId: me.branchId, customerName: customerName.trim() || undefined, customerPhone: customerPhone.trim() || undefined, lines: lines.map((l) => ({ productId: l.productId, label: l.product.label, quantity: l.quantity, unitPrice: l.product.unitPrice, })), total, clientSubmittedAt: new Date().toISOString(), }); setSubmitting(false); setScreen({ kind: "receipt", sale }); setCart([]); await refreshCounts(me.id); // Sync in the background; a failure leaves the sale queued for the // next automatic retry. if (navigator.onLine) { flushQueue(owner).then(async (r) => { setCounts((c) => ({ ...c, pending: r.pending })); setServerReachable(!r.offline); const rows = await salesFor(me.id); const updated = rows.find((row) => row.clientUuid === sale.clientUuid); if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s)); if (r.rejected > 0) refreshCounts(me.id); }); } } async function startMpesaPush() { if (!validate(true)) return; if (lines.length !== 1) { setFormError("M-Pesa payments support one product per payment. Use cash for mixed sales."); return; } if (!navigator.onLine) { setFormError("M-Pesa requires an active network connection."); return; } setSubmitting(true); try { const data = await apiFetch<{ transactionId: string }>("/api/mpesa/stkpush", { method: "POST", body: JSON.stringify({ customerName: customerName || undefined, customerPhone, productId: lines[0].productId, quantity: lines[0].quantity, }), }); setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" }); beginPolling(data.transactionId); } catch (err) { const reason = err instanceof ApiError ? err.message : "Network error while contacting M-Pesa."; setScreen({ kind: "mpesa", transactionId: "", ui: "FAILED", reason }); } setSubmitting(false); } function beginPolling(transactionId: string) { if (pollRef.current) clearInterval(pollRef.current); const start = Date.now(); pollRef.current = setInterval(async () => { try { const data = await apiFetch<{ paymentStatus: string; mpesaDetails?: { receiptNumber?: string | null; resultDesc?: string | null }; }>("/api/mpesa/status/" + transactionId); if (data.paymentStatus === "SUCCESS") { clearInterval(pollRef.current!); setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber }); } else if (data.paymentStatus === "FAILED") { clearInterval(pollRef.current!); setScreen({ kind: "mpesa", transactionId, ui: "FAILED", reason: data.mpesaDetails?.resultDesc || "Payment was not completed.", }); } else if (Date.now() - start > 60000) { clearInterval(pollRef.current!); // Not "failed": the customer may still approve it, and the callback // would then record it as paid. Taking cash now would count it twice. setScreen({ kind: "mpesa", transactionId, ui: "FAILED", reason: "No confirmation yet. Do NOT take cash for this sale until a manager has checked whether the M-Pesa payment arrived.", }); } } catch { // transient network hiccup while polling; keep trying until timeout above } }, 3000); } async function openQueue() { if (!me) return; setQueueRows((await salesFor(me.id)).filter((r) => r.state !== "synced")); setShowQueue(true); } async function handleDismiss(clientUuid: string) { if (!me) return; if (!window.confirm("Remove this sale from the phone? Make sure your manager has a record of it first.")) return; await dismissRejected(clientUuid); setQueueRows((rows) => rows.filter((r) => r.clientUuid !== clientUuid)); await refreshCounts(me.id); } async function handleSignOut() { if (counts.pending > 0) { const ok = window.confirm( `${counts.pending} sale(s) have not synced yet. They stay safely on this phone and sync the next time you sign in here. Sign out anyway?` ); if (!ok) return; } try { await apiFetch("/api/auth/logout", { method: "POST" }); } catch { // Offline: the cookies expire on their own; still clear this device. } clearSession(); router.replace("/login"); } if (bootError) { return (

{bootError}

); } if (!me) { return (
); } const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity])); const connected = online && serverReachable; return (

{me.branchName || "Branch"}

{me.name}

{connected ? "● Online" : "● Offline"}
{(counts.pending > 0 || counts.rejected > 0) && ( )} {screen.kind === "receipt" ? (
) : ( <> {productsLoading ? (
) : ( )} {lines.length > 0 && (

This sale

    {lines.map((l) => (
  • {l.product.label}

    KES {(l.quantity * l.product.unitPrice).toLocaleString()}

    {l.quantity}
  • ))}
Total KES {total.toLocaleString()}
)}
{!showCustomer ? ( ) : ( <> setCustomerName(e.target.value)} placeholder="e.g. Jane Wambui" className="tap-target mb-3 w-full rounded-xl border border-depth-200 px-3" /> { setCustomerPhone(e.target.value); if (phoneError) setPhoneError(null); }} placeholder="07XXXXXXXX" className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")} /> {phoneError &&

{phoneError}

} )}
{formError &&

{formError}

}
{mpesaEnabled && } {method === "CASH" || !mpesaEnabled ? ( ) : ( )}
)}
{showQueue && (

Sales on this phone

{queueRows.length === 0 &&

Everything is synced.

}
    {queueRows.map((r) => (
  • {new Date(r.clientSubmittedAt).toLocaleString("en-KE")} KES {r.total.toLocaleString()}

    {r.lines.map((l) => `${l.quantity} x ${l.label}`).join(", ")}

    {r.state === "pending" ? (

    Waiting to sync

    ) : ( <>

    Not accepted: {r.error}

    Tell your manager, then remove it from this phone.

    )}
  • ))}
)} {screen.kind === "mpesa" && ( { setScreen({ kind: "idle" }); startMpesaPush(); }} onNewSale={resetForm} onDismiss={() => setScreen({ kind: "idle" })} /> )}
); }