Show Offline when the server is unreachable, and document the pilot changes
The terminal's badge only looked at navigator.onLine, so it said Online while the server was down. It now also reflects whether the last sync or health check reached the server. The cart badge no longer covers the product name. docs/PILOT_READINESS.md records what changed against the PRD review, the decisions taken (offline sales may take stock negative, transfers may not, server price wins but differences are flagged), rollout notes, and what is still open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,9 @@ export default function TerminalPage() {
|
||||
const [mpesaEnabled, setMpesaEnabled] = useState(false);
|
||||
const [bootError, setBootError] = useState<string | null>(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<QueuedSale[]>([]);
|
||||
@@ -98,6 +101,7 @@ export default function TerminalPage() {
|
||||
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");
|
||||
}
|
||||
@@ -137,6 +141,7 @@ export default function TerminalPage() {
|
||||
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");
|
||||
@@ -235,6 +240,7 @@ export default function TerminalPage() {
|
||||
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));
|
||||
@@ -374,6 +380,7 @@ export default function TerminalPage() {
|
||||
}
|
||||
|
||||
const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity]));
|
||||
const connected = online && serverReachable;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-sand-50 pb-10">
|
||||
@@ -387,10 +394,10 @@ export default function TerminalPage() {
|
||||
<span
|
||||
className={
|
||||
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
||||
(online ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
|
||||
(connected ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
|
||||
}
|
||||
>
|
||||
{online ? "● Online" : "● Offline"}
|
||||
{connected ? "● Online" : "● Offline"}
|
||||
</span>
|
||||
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||
Sign out
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-depth-600">Tap a product to add it</span>
|
||||
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
||||
<span className="mb-2 block text-sm font-medium text-depth-600">Tap a product to add it</span>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{products.map((p) => {
|
||||
const inCart = quantities[p._id] ?? 0;
|
||||
@@ -31,8 +31,8 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
|
||||
onClick={() => onAdd(p._id)}
|
||||
aria-label={`Add ${p.label}`}
|
||||
className={
|
||||
"tap-target relative flex flex-col items-start justify-center rounded-xl2 border-2 px-3 py-2 text-left transition active:scale-[0.97] " +
|
||||
(inCart > 0 ? "border-flow-500 bg-flow-500/10" : "border-depth-200 bg-white")
|
||||
"tap-target relative flex flex-col items-start justify-center rounded-xl2 border-2 py-2 pl-3 text-left transition active:scale-[0.97] " +
|
||||
(inCart > 0 ? "border-flow-500 bg-flow-500/10 pr-9" : "border-depth-200 bg-white pr-3")
|
||||
}
|
||||
>
|
||||
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
|
||||
|
||||
@@ -217,6 +217,11 @@ async function doFlush(owner: QueueOwner): Promise<FlushResult> {
|
||||
let synced = 0;
|
||||
let rejected = 0;
|
||||
let offline = false;
|
||||
if (pending.length === 0) {
|
||||
// Nothing to send: still find out whether the server is reachable, so
|
||||
// the terminal's Online/Offline badge is truthful.
|
||||
offline = !(await fetch("/api/health", { cache: "no-store" }).then((r) => r.ok).catch(() => false));
|
||||
}
|
||||
for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) {
|
||||
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user