feat(rep-checkin): unified picker for pinned and unpinned customers
The rep check-in screen now treats pinned and unpinned customers through a single interface instead of two stacked sections. The bottom-sheet picker opens from a new 'Add a customer' button below the nearby list, shows every customer with unpinned rows first, and stamps a 'no location yet — saving will pin this spot' badge on each so the rep sees what ticking will do. The success toast now reports the pin count when any selected customer was unpinned (e.g. '5 visits logged · saved this spot for 2 new customers'), giving the rep visible confirmation that coordinates were written. Pure front-end change on top of the existing pinCustomer path — no server or schema work. New helpers: - picker-sort: sortCustomersForPicker + filterPickerCustomers - visit-toast: formatVisitToast (plurals, pin suffix, offline hint) 19 new tests; rep + visits scope stays at 66/66 green.
This commit is contained in:
parent
cbf45048f2
commit
38c65af8e3
5 changed files with 507 additions and 9 deletions
|
|
@ -1,11 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MapPin, Search, Check, WifiOff, RefreshCw, Loader2, Navigation } from "lucide-react";
|
||||
import {
|
||||
MapPin,
|
||||
Search,
|
||||
Check,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Navigation,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { rankNearbyCustomers, DEFAULT_RADIUS_KM } from "@/lib/visit-matching";
|
||||
import type { CustomerSnapshotRow } from "@/lib/server/visits-service";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
enqueue,
|
||||
flushOutbox,
|
||||
|
|
@ -14,6 +30,12 @@ import {
|
|||
writeCachedSnapshot,
|
||||
} from "./outbox";
|
||||
import { formatSnapshotAge, isSnapshotStale } from "./snapshot-freshness";
|
||||
import {
|
||||
sortCustomersForPicker,
|
||||
filterPickerCustomers,
|
||||
type PickerCustomer,
|
||||
} from "./picker-sort";
|
||||
import { formatVisitToast } from "./visit-toast";
|
||||
|
||||
/** Radii the "widen search" button steps through, in km. */
|
||||
const RADIUS_STEPS = [DEFAULT_RADIUS_KM, 5, 15];
|
||||
|
|
@ -35,6 +57,8 @@ export function CheckInView() {
|
|||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [pinning, setPinning] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickerSearch, setPickerSearch] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pending, setPending] = useState(0);
|
||||
|
|
@ -233,6 +257,32 @@ export function CheckInView() {
|
|||
.slice(0, 20);
|
||||
}, [search, customers, nearby]);
|
||||
|
||||
/* ------------------------------------------------------------- picker */
|
||||
|
||||
// Sorted once (unpinned first — that's the primary picker use-case,
|
||||
// pinning a customer who isn't on the nearby list), then filtered by
|
||||
// the sheet's own search term. Both steps live in pure helpers so the
|
||||
// rule is testable without the sheet/React on top.
|
||||
const pickerRows = useMemo(
|
||||
() => sortCustomersForPicker(customers),
|
||||
[customers]
|
||||
);
|
||||
|
||||
const pickerResults = useMemo(
|
||||
() => filterPickerCustomers(pickerRows, pickerSearch),
|
||||
[pickerRows, pickerSearch]
|
||||
);
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
setPickerSearch("");
|
||||
setPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const closePicker = useCallback(() => {
|
||||
setPickerOpen(false);
|
||||
setPickerSearch("");
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string, hasCoordinates: boolean) => {
|
||||
setSelected((prev) => {
|
||||
|
|
@ -275,6 +325,7 @@ export function CheckInView() {
|
|||
});
|
||||
|
||||
const count = selected.size;
|
||||
const pinnedCount = pinning.size;
|
||||
setSelected(new Set());
|
||||
setPinning(new Set());
|
||||
setNotes("");
|
||||
|
|
@ -285,13 +336,24 @@ export function CheckInView() {
|
|||
try {
|
||||
const result = await flushOutbox(pb.authStore.token);
|
||||
refreshPending();
|
||||
if (result.sent > 0) {
|
||||
toast.success(`${count} visit${count === 1 ? "" : "s"} logged`);
|
||||
} else {
|
||||
toast.success(`${count} visit${count === 1 ? "" : "s"} saved — will sync when you have signal`);
|
||||
}
|
||||
toast.success(
|
||||
formatVisitToast({
|
||||
count,
|
||||
pinned: pinnedCount,
|
||||
delivered: result.sent > 0,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
toast.success(`${count} visit${count === 1 ? "" : "s"} saved offline`);
|
||||
// Network or unexpected failure — entry is still queued and will
|
||||
// be retried by the next flush; surface the offline variant so
|
||||
// the rep knows coordinates will land when signal comes back.
|
||||
toast.success(
|
||||
formatVisitToast({
|
||||
count,
|
||||
pinned: pinnedCount,
|
||||
delivered: false,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
void entry;
|
||||
setSubmitting(false);
|
||||
|
|
@ -402,8 +464,9 @@ export function CheckInView() {
|
|||
|
||||
{nearby.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No customers pinned within {radiusKm} km yet. Search below — saving a
|
||||
visit records this spot for next time.
|
||||
No customers pinned within {radiusKm} km yet. Use{" "}
|
||||
<span className="font-medium text-foreground">Add a customer</span>{" "}
|
||||
below — saving a visit records this spot for next time.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
|
|
@ -438,6 +501,16 @@ export function CheckInView() {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{position && (
|
||||
<button
|
||||
onClick={openPicker}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed bg-card px-4 py-3 text-sm font-medium text-foreground transition-colors active:bg-primary/5"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add a customer
|
||||
</button>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<label className="relative block">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
|
|
@ -500,6 +573,78 @@ export function CheckInView() {
|
|||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sheet open={pickerOpen} onOpenChange={(open) => (open ? setPickerOpen(true) : closePicker())}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="rounded-t-2xl px-0 pb-0 sm:max-w-md sm:mx-auto"
|
||||
showCloseButton
|
||||
>
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<UserPlus className="size-4" />
|
||||
Add a customer
|
||||
{selected.size > 0 && (
|
||||
<span className="ml-auto rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Customers without a location are listed first — ticking one
|
||||
pins this spot onto their record.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-4 pb-3">
|
||||
<label className="relative block">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={pickerSearch}
|
||||
onChange={(event) => setPickerSearch(event.target.value)}
|
||||
placeholder="Search by name or company"
|
||||
className="w-full rounded-xl border bg-background py-2.5 pl-9 pr-3 text-sm outline-none focus:border-primary"
|
||||
aria-label="Search customers"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-shell__body flex-1 overflow-y-auto px-4 pb-4">
|
||||
{pickerResults.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{customers.length === 0
|
||||
? "Customer list is still loading."
|
||||
: `No customers match “${pickerSearch.trim()}”.`}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{pickerResults.map((row) => (
|
||||
<PickerRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
checked={selected.has(row.id)}
|
||||
onToggle={() => toggle(row.id, !row.isUnpinned)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 border-t bg-background p-4">
|
||||
<button
|
||||
onClick={closePicker}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground"
|
||||
>
|
||||
Done
|
||||
{selected.size > 0 && (
|
||||
<span className="ml-1 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-primary-foreground px-1.5 text-[11px] font-bold text-primary">
|
||||
{selected.size}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -546,3 +691,57 @@ function CustomerRow({
|
|||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row in the "Add a customer" picker. Same hit area as `CustomerRow`
|
||||
* but adds a "no location yet — saving will pin this spot" subtitle
|
||||
* for unpinned rows so the rep knows what ticking will do.
|
||||
*/
|
||||
function PickerRow({
|
||||
row,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
row: PickerCustomer;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const sub = row.isUnpinned
|
||||
? `${row.companyName ?? "—"} · no location yet — saving will pin this spot`
|
||||
: (row.companyName ?? "—");
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-pressed={checked}
|
||||
aria-label={
|
||||
row.isUnpinned
|
||||
? `${row.customer}, ${row.companyName ?? "no company"}, no location — ticking will pin this spot`
|
||||
: `${row.customer}, ${row.companyName ?? "no company"}`
|
||||
}
|
||||
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors ${
|
||||
checked ? "border-primary bg-primary/8" : "bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border ${
|
||||
checked ? "border-primary bg-primary text-primary-foreground" : "bg-background"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="block truncate text-sm font-medium">{row.customer}</span>
|
||||
{row.isUnpinned && (
|
||||
<span className="shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-400">
|
||||
no location
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{sub}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
142
src/app/(rep)/rep/picker-sort.test.ts
Normal file
142
src/app/(rep)/rep/picker-sort.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { CustomerSnapshotRow } from "@/lib/server/visits-service";
|
||||
import {
|
||||
sortCustomersForPicker,
|
||||
filterPickerCustomers,
|
||||
} from "./picker-sort";
|
||||
|
||||
/**
|
||||
* Helper to keep fixture rows readable. Default: pinned customer at
|
||||
* Carletonville with `customer` = id, so test failures are easy to
|
||||
* read in the diff.
|
||||
*/
|
||||
function row(over: Partial<CustomerSnapshotRow> & { id: string }): CustomerSnapshotRow {
|
||||
return {
|
||||
id: over.id,
|
||||
customer: over.customer ?? over.id,
|
||||
companyName: over.companyName ?? null,
|
||||
rep: over.rep ?? "",
|
||||
...(over.lat !== undefined ? { lat: over.lat } : {}),
|
||||
...(over.lon !== undefined ? { lon: over.lon } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortCustomersForPicker", () => {
|
||||
it("puts unpinned customers first — the primary picker use-case is pinning", () => {
|
||||
const result = sortCustomersForPicker([
|
||||
row({ id: "pinned", lat: -26.4, lon: 27.4 }),
|
||||
row({ id: "unpinned" }),
|
||||
]);
|
||||
|
||||
expect(result.map((r) => r.id)).toEqual(["unpinned", "pinned"]);
|
||||
});
|
||||
|
||||
it("treats a customer missing only one of lat/lon as unpinned", () => {
|
||||
const result = sortCustomersForPicker([
|
||||
row({ id: "lon-only", lat: -26.4 }), // no lon
|
||||
row({ id: "lat-only", lon: 27.4 }), // no lat
|
||||
]);
|
||||
|
||||
// Both count as unpinned; their relative order falls back to the
|
||||
// name sort, which puts "lat-only" before "lon-only".
|
||||
expect(result.every((r) => r.isUnpinned)).toBe(true);
|
||||
expect(result.map((r) => r.id)).toEqual(["lat-only", "lon-only"]);
|
||||
});
|
||||
|
||||
it("sorts alphabetically within each group by contact name", () => {
|
||||
const result = sortCustomersForPicker([
|
||||
row({ id: "charlie", customer: "Charlie" }),
|
||||
row({ id: "alpha", customer: "Alpha" }),
|
||||
row({ id: "bravo", customer: "Bravo", lat: -26.4, lon: 27.4 }),
|
||||
row({ id: "delta", customer: "Delta", lat: -26.4, lon: 27.4 }),
|
||||
]);
|
||||
|
||||
expect(result.map((r) => r.id)).toEqual([
|
||||
"alpha", // unpinned, alphabetical
|
||||
"charlie",
|
||||
"bravo", // pinned, alphabetical
|
||||
"delta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sinks empty-name rows to the bottom of their group", () => {
|
||||
// Snapshot loader fills missing `customer` with "" — without this
|
||||
// rule every empty-name row would cluster at "A" and push real
|
||||
// entries down.
|
||||
const result = sortCustomersForPicker([
|
||||
row({ id: "blank1", customer: "" }),
|
||||
row({ id: "alpha", customer: "Alpha" }),
|
||||
row({ id: "blank2", customer: "", lat: -26.4, lon: 27.4 }),
|
||||
row({ id: "zulu", customer: "Zulu", lat: -26.4, lon: 27.4 }),
|
||||
]);
|
||||
|
||||
expect(result.map((r) => r.id)).toEqual([
|
||||
"alpha",
|
||||
"blank1",
|
||||
"zulu",
|
||||
"blank2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags each row with isUnpinned for the UI to badge", () => {
|
||||
const result = sortCustomersForPicker([
|
||||
row({ id: "pinned", lat: -26.4, lon: 27.4 }),
|
||||
row({ id: "unpinned" }),
|
||||
]);
|
||||
|
||||
expect(result[0]).toMatchObject({ id: "unpinned", isUnpinned: true });
|
||||
expect(result[1]).toMatchObject({ id: "pinned", isUnpinned: false });
|
||||
});
|
||||
|
||||
it("returns an empty list when given no rows", () => {
|
||||
expect(sortCustomersForPicker([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const input = [
|
||||
row({ id: "b" }),
|
||||
row({ id: "a", lat: -26.4, lon: 27.4 }),
|
||||
];
|
||||
const snapshot = JSON.stringify(input);
|
||||
sortCustomersForPicker(input);
|
||||
expect(JSON.stringify(input)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterPickerCustomers", () => {
|
||||
const fixture = sortCustomersForPicker([
|
||||
row({ id: "alpha-unpinned", customer: "Alpha Coetzee" }),
|
||||
row({ id: "bravo-unpinned", customer: "Bravo de Klerk" }),
|
||||
row({ id: "charlie-pinned", customer: "Charlie Smith", companyName: "Sibanye" }),
|
||||
row({ id: "delta-pinned", customer: "Delta Jones", companyName: "Harmony", lat: -26.4, lon: 27.4 }),
|
||||
]);
|
||||
|
||||
it("returns the input unchanged when the term is empty", () => {
|
||||
expect(filterPickerCustomers(fixture, "")).toBe(fixture);
|
||||
});
|
||||
|
||||
it("returns the input unchanged when the term is whitespace", () => {
|
||||
// `safeRead`-style fallback: a stray space shouldn't filter
|
||||
// everyone out.
|
||||
expect(filterPickerCustomers(fixture, " ")).toBe(fixture);
|
||||
});
|
||||
|
||||
it("matches case-insensitively on contact name", () => {
|
||||
const result = filterPickerCustomers(fixture, "ALPHA");
|
||||
expect(result.map((r) => r.id)).toEqual(["alpha-unpinned"]);
|
||||
});
|
||||
|
||||
it("matches case-insensitively on company name", () => {
|
||||
const result = filterPickerCustomers(fixture, "sibanye");
|
||||
expect(result.map((r) => r.id)).toEqual(["charlie-pinned"]);
|
||||
});
|
||||
|
||||
it("matches as a substring, not just a prefix", () => {
|
||||
const result = filterPickerCustomers(fixture, "klerk");
|
||||
expect(result.map((r) => r.id)).toEqual(["bravo-unpinned"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when nothing matches", () => {
|
||||
expect(filterPickerCustomers(fixture, "zzz-no-such-customer")).toEqual([]);
|
||||
});
|
||||
});
|
||||
68
src/app/(rep)/rep/picker-sort.ts
Normal file
68
src/app/(rep)/rep/picker-sort.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Sort + filter for the "Add a customer" bottom sheet.
|
||||
*
|
||||
* Pure functions, no React, no PocketBase. Extracted so the ordering
|
||||
* rule is testable in isolation — the choice of "unpinned first" is
|
||||
* the whole point of the picker (it's how a rep reaches the customer
|
||||
* who doesn't appear in the nearby list, and ticking them is what
|
||||
* pins this spot onto their record), and any regression here would
|
||||
* silently make the picker feel like a duplicate of the nearby list.
|
||||
*/
|
||||
|
||||
import type { CustomerSnapshotRow } from "@/lib/server/visits-service";
|
||||
|
||||
export interface PickerCustomer extends CustomerSnapshotRow {
|
||||
/** True when the row has no coordinates yet — ticking will pin it. */
|
||||
isUnpinned: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort customers for the picker.
|
||||
*
|
||||
* Unpinned customers first (primary use-case: pin someone who isn't
|
||||
* on the nearby list), then already-pinned ones. Within each group,
|
||||
* alphabetical by contact name; rows with an empty contact name sink
|
||||
* to the bottom so they don't crowd out real entries at "A".
|
||||
*/
|
||||
export function sortCustomersForPicker(
|
||||
customers: CustomerSnapshotRow[]
|
||||
): PickerCustomer[] {
|
||||
const pinned: PickerCustomer[] = [];
|
||||
const unpinned: PickerCustomer[] = [];
|
||||
|
||||
for (const c of customers) {
|
||||
const isUnpinned = c.lat === undefined || c.lon === undefined;
|
||||
const row: PickerCustomer = { ...c, isUnpinned };
|
||||
(isUnpinned ? unpinned : pinned).push(row);
|
||||
}
|
||||
|
||||
const byName = (a: { customer: string }, b: { customer: string }) => {
|
||||
const aEmpty = a.customer.trim() === "";
|
||||
const bEmpty = b.customer.trim() === "";
|
||||
if (aEmpty !== bEmpty) return aEmpty ? 1 : -1;
|
||||
return a.customer.localeCompare(b.customer);
|
||||
};
|
||||
|
||||
unpinned.sort(byName);
|
||||
pinned.sort(byName);
|
||||
|
||||
return [...unpinned, ...pinned];
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring filter on contact name or company name.
|
||||
* Empty term returns the input unchanged — keeps callers from having
|
||||
* to special-case "no filter" and keeps the memo key stable.
|
||||
*/
|
||||
export function filterPickerCustomers(
|
||||
customers: PickerCustomer[],
|
||||
term: string
|
||||
): PickerCustomer[] {
|
||||
const t = term.trim().toLowerCase();
|
||||
if (t.length === 0) return customers;
|
||||
return customers.filter(
|
||||
(c) =>
|
||||
c.customer.toLowerCase().includes(t) ||
|
||||
(c.companyName ?? "").toLowerCase().includes(t)
|
||||
);
|
||||
}
|
||||
47
src/app/(rep)/rep/visit-toast.test.ts
Normal file
47
src/app/(rep)/rep/visit-toast.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { formatVisitToast } from "./visit-toast";
|
||||
|
||||
describe("formatVisitToast", () => {
|
||||
it("says 'visit' for a single batch and 'visits' for multiple", () => {
|
||||
expect(formatVisitToast({ count: 1, pinned: 0, delivered: true })).toBe(
|
||||
"1 visit logged"
|
||||
);
|
||||
expect(formatVisitToast({ count: 3, pinned: 0, delivered: true })).toBe(
|
||||
"3 visits logged"
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the pin suffix when no customers were pinned", () => {
|
||||
// A rep at a customer who already has coordinates should not see
|
||||
// a confusing "saved this spot" line.
|
||||
expect(formatVisitToast({ count: 3, pinned: 0, delivered: true })).toBe(
|
||||
"3 visits logged"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the pin suffix with singular 'customer' for exactly one pin", () => {
|
||||
expect(
|
||||
formatVisitToast({ count: 1, pinned: 1, delivered: true })
|
||||
).toBe("1 visit logged · saved this spot for 1 new customer");
|
||||
});
|
||||
|
||||
it("pluralises to 'customers' on more than one pin", () => {
|
||||
expect(
|
||||
formatVisitToast({ count: 5, pinned: 2, delivered: true })
|
||||
).toBe("5 visits logged · saved this spot for 2 new customers");
|
||||
});
|
||||
|
||||
it("uses 'saved' (not 'logged') and adds the offline hint when the batch wasn't delivered", () => {
|
||||
expect(formatVisitToast({ count: 3, pinned: 0, delivered: false })).toBe(
|
||||
"3 visits saved — will sync when you have signal"
|
||||
);
|
||||
});
|
||||
|
||||
it("combines the pin suffix and the offline hint in the right order", () => {
|
||||
// Pin first (the more important fact — coordinates were saved),
|
||||
// then the offline hint (it'll reach the server eventually).
|
||||
expect(formatVisitToast({ count: 1, pinned: 1, delivered: false })).toBe(
|
||||
"1 visit saved · saved this spot for 1 new customer — will sync when you have signal"
|
||||
);
|
||||
});
|
||||
});
|
||||
42
src/app/(rep)/rep/visit-toast.ts
Normal file
42
src/app/(rep)/rep/visit-toast.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Format the "visit logged" toast after a check-in is submitted.
|
||||
*
|
||||
* Pure function — extracted so the message rule is testable. The two
|
||||
* facts the rep needs to see are:
|
||||
* 1. How many visits went into the batch.
|
||||
* 2. Whether any coordinates were actually pinned (the whole reason
|
||||
* for visiting an unpinned customer), and whether it needs a
|
||||
* later sync.
|
||||
*
|
||||
* Reps at a mine gate can't see what the server did; if the toast
|
||||
* dropped the "X new customers pinned" line, the rep would have no
|
||||
* way to know the coordinates were saved.
|
||||
*/
|
||||
|
||||
export interface VisitToastInput {
|
||||
/** How many visits were submitted in this batch. */
|
||||
count: number;
|
||||
/** How many of those carried `pinCustomer: true` — new coordinates written. */
|
||||
pinned: number;
|
||||
/** Did the server ack this batch, or is it queued for later sync? */
|
||||
delivered: boolean;
|
||||
}
|
||||
|
||||
export function formatVisitToast({
|
||||
count,
|
||||
pinned,
|
||||
delivered,
|
||||
}: VisitToastInput): string {
|
||||
const visits = `${count} ${count === 1 ? "visit" : "visits"}`;
|
||||
let line = `${visits} ${delivered ? "logged" : "saved"}`;
|
||||
|
||||
if (pinned > 0) {
|
||||
line += ` · saved this spot for ${pinned} new ${pinned === 1 ? "customer" : "customers"}`;
|
||||
}
|
||||
|
||||
if (!delivered) {
|
||||
line += " — will sync when you have signal";
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
Loading…
Reference in a new issue