WFDASH-WEBAPP/docs/superpowers/plans/2026-08-30-wfsales-pos-implementation.md
2026-08-31 02:25:28 +02:00

75 KiB

WFSALES POS Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Turn the existing WFSALES POS mockup into a safe, correctly-wired counter-sale app served from WFDASH-WEBAPP, backed by stock_items/stock_ledger, with no PocketBase credentials shipped to the client.

Architecture: New src/lib/server/pos-service.ts + three thin /api/v1/pos/* routes (catalog, checkout, sales) hold all PocketBase access server-side, authenticated by a POS_API_KEY header (same shape as the existing AGENT_API_KEY/assertAgentAuth pattern). The existing static PWA (HTML/JS, currently living outside this repo) is relocated into public/pos/ and rewired to call those routes instead of talking to PocketBase directly with a hardcoded superuser password.

Tech Stack: Next.js 16 (App Router, route handlers), PocketBase JS SDK (server-side admin client via pocketbase-admin.ts), Vitest for tests, vanilla JS/HTML for the POS frontend (unchanged from the existing mockup's approach — no React).

Reference: design spec at docs/superpowers/specs/2026-08-30-wfsales-pos-design.md. One refinement vs. the spec: instead of adding a new pos_sale reason value to stock_ledger (which would require a PB admin step before any code could run), checkout uses the existing customer_fulfillment reason value (already valid per STOCK_REASON_VALUES in src/lib/types.ts — "qty booked out when a customer order is fulfilled from existing stock"), with reference_type: "pos_sale" and reference_id: <pos_sales id> distinguishing POS sales in the ledger. This removes one manual PB-admin dependency from the rollout.

Source mockup location (read-only reference, not part of this repo): /Volumes/KIOXIA/Library/Application Support/Open Design/namespaces/release-stable/data/projects/9147f587-4959-4659-a6ba-a11f629a783c/


Manual prerequisite (user, PocketBase admin) — do this before Task 9

Per AGENTS.md, schema changes are made by the user in the PB admin UI, not by app code. Create these before the checkout endpoint is used against real data (backend tasks 1-8 can be built and unit-tested without this, since tests use a stubbed PB client):

  1. New collection pos_sales: sale_number (text), payment_method (select: cash, card), cash_given (number), change_given (number), subtotal (number), vat (number), total (number), customer (relation → customers, single, optional).
  2. New collection pos_sale_items: sale (relation → pos_sales, single, required), stock_item (relation → stock_items, single, required), sku (text), description (text), qty (number), unit_cost (number), unit_price (number).
  3. Both collections: no public API rules (list/view/create/update/delete all blank/null) — only the server's superuser credentials touch them, same as stock_items/stock_ledger.
  4. Set a POS_API_KEY env var (any long random string) on the WFDASH-WEBAPP deployment, alongside the existing AGENT_API_KEY/PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD.

Part 1 — Backend

Task 1: POS types

Files:

  • Modify: src/lib/types.ts (append after the StockLedger interface, around line 1525)

  • Step 1: Add the types

// ─────────────────────────────────────────────────────────────────
// WFSALES POS (standalone counter-sale app) — added 2026-08-30
// ─────────────────────────────────────────────────────────────────

/** Shape returned by GET /api/v1/pos/catalog — flattened stock_items row. */
export interface PosCatalogItem {
  id: string;
  sku: string;
  name: string;
  description: string;
  unit: string;
  /** Expanded category name, or "" if uncategorised. */
  category: string;
  current_qty: number;
  selling_price: number;
  cost_price: number;
}

export interface PosSale extends BaseRecord {
  sale_number: string;
  payment_method: "cash" | "card";
  cash_given: number;
  change_given: number;
  subtotal: number;
  vat: number;
  total: number;
  /** customers relation id, or "" for a walk-in with no customer picked. */
  customer: string;
}

export interface PosSaleItem extends BaseRecord {
  sale: string;
  stock_item: string;
  sku: string;
  description: string;
  qty: number;
  unit_cost: number;
  unit_price: number;
}
  • Step 2: Typecheck

Run: npx tsc --noEmit Expected: no new errors.

  • Step 3: Commit
git add src/lib/types.ts
git commit -m "feat(pos): add PosCatalogItem/PosSale/PosSaleItem types"

Task 2: pos-auth.ts

Files:

  • Create: src/lib/server/pos-auth.ts

  • Test: src/lib/server/pos-auth.test.ts

  • Step 1: Write the failing test

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { assertPosAuth } from "./pos-auth";

describe("assertPosAuth", () => {
  const ORIGINAL_KEY = process.env.POS_API_KEY;

  beforeEach(() => {
    process.env.POS_API_KEY = "test-pos-secret";
  });

  afterEach(() => {
    if (ORIGINAL_KEY === undefined) {
      delete process.env.POS_API_KEY;
    } else {
      process.env.POS_API_KEY = ORIGINAL_KEY;
    }
  });

  function reqWith(authHeader: string | null) {
    const headers: Record<string, string> = {};
    if (authHeader !== null) headers.authorization = authHeader;
    return new NextRequest("http://localhost/api/v1/pos/catalog", { headers });
  }

  it("throws when POS_API_KEY env var is missing", () => {
    delete process.env.POS_API_KEY;
    expect(() => assertPosAuth(reqWith("Bearer test-pos-secret"))).toThrow(
      /POS_API_KEY is not configured/
    );
  });

  it("accepts a matching bearer token", () => {
    expect(() => assertPosAuth(reqWith("Bearer test-pos-secret"))).not.toThrow();
  });

  it("rejects a wrong bearer token", () => {
    expect(() => assertPosAuth(reqWith("Bearer wrong"))).toThrow(/Unauthorized/);
  });

  it("rejects a request with no Authorization header", () => {
    expect(() => assertPosAuth(reqWith(null))).toThrow(/Unauthorized/);
  });

  it("tags the thrown error with status 401", () => {
    try {
      assertPosAuth(reqWith("Bearer wrong"));
      throw new Error("expected to throw");
    } catch (err) {
      expect((err as Error & { status?: number }).status).toBe(401);
    }
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/pos-auth.test.ts Expected: FAIL — cannot find module ./pos-auth.

  • Step 3: Write the implementation
import { NextRequest } from "next/server";

/**
 * Device-scoped secret, distinct from AGENT_API_KEY — a POS device
 * only needs checkout/catalog/sales access, not the full agent API
 * surface, so it gets its own key rather than reusing the agent's.
 */
export function assertPosAuth(req: NextRequest) {
  const expected = process.env.POS_API_KEY;
  if (!expected) {
    throw new Error("POS_API_KEY is not configured.");
  }

  const authHeader = req.headers.get("authorization") || "";
  const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";

  if (token !== expected) {
    const error = new Error("Unauthorized");
    (error as Error & { status?: number }).status = 401;
    throw error;
  }
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/pos-auth.test.ts Expected: PASS (5 tests)

  • Step 5: Commit
git add src/lib/server/pos-auth.ts src/lib/server/pos-auth.test.ts
git commit -m "feat(pos): add assertPosAuth for POS_API_KEY-gated routes"

Task 3: pos-service.ts — listActiveStockCatalog

Files:

  • Create: src/lib/server/pos-service.ts

  • Test: src/lib/server/pos-service.test.ts

  • Step 1: Write the failing test

import { describe, it, expect, vi } from "vitest";
import { listActiveStockCatalog } from "./pos-service";

function stubPb(items: unknown[]) {
  return {
    collection: () => ({
      getFullList: vi.fn().mockResolvedValue(items),
      getList: vi.fn(),
      getOne: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
    }),
  };
}

describe("listActiveStockCatalog", () => {
  it("flattens stock_items rows, pulling the expanded category name", async () => {
    const pb = stubPb([
      {
        id: "si1",
        sku: "SKU-1",
        name: "Widget",
        description: "A widget",
        unit: "ea",
        current_qty: 12,
        selling_price: 99.5,
        cost_price: 50,
        expand: { category: { name: "Hardware" } },
      },
      {
        id: "si2",
        sku: "SKU-2",
        name: "Uncategorised thing",
        description: "",
        unit: "ea",
        current_qty: 0,
        selling_price: 10,
        cost_price: 5,
      },
    ]);

    const result = await listActiveStockCatalog(pb);

    expect(result).toEqual([
      {
        id: "si1",
        sku: "SKU-1",
        name: "Widget",
        description: "A widget",
        unit: "ea",
        category: "Hardware",
        current_qty: 12,
        selling_price: 99.5,
        cost_price: 50,
      },
      {
        id: "si2",
        sku: "SKU-2",
        name: "Uncategorised thing",
        description: "",
        unit: "ea",
        category: "",
        current_qty: 0,
        selling_price: 10,
        cost_price: 5,
      },
    ]);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/pos-service.test.ts Expected: FAIL — cannot find module ./pos-service.

  • Step 3: Write the implementation
import type { PosCatalogItem, PosSale, PosSaleItem, StockItem } from "@/lib/types";

/**
 * PB client type kept loose so both the real `PocketBase` instance
 * and test stubs satisfy the contract — same approach as
 * `stock-items-service.ts`'s `StockItemsPb`.
 */
export type PosPb = {
  collection: (name: string) => {
    getFullList: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
    getList: <T = unknown>(
      page: number,
      perPage: number,
      opts?: Record<string, unknown>,
    ) => Promise<{ items: T[] }>;
    getOne: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
    create: <T = unknown>(data: Record<string, unknown>) => Promise<T>;
    update: <T = unknown>(id: string, data: Record<string, unknown>) => Promise<T>;
  };
};

// ─── listActiveStockCatalog ────────────────────────────────────────

type StockItemWithCategory = StockItem & {
  expand?: { category?: { name?: string } };
};

/**
 * Catalog source for the POS: active stock_items only, flattened so
 * the frontend doesn't need to know about PB's relation/expand shape.
 */
export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[]> {
  const items = await pb.collection("stock_items").getFullList<StockItemWithCategory>({
    filter: "is_active = true",
    sort: "name",
    expand: "category",
  });

  return items.map((it) => ({
    id: it.id,
    sku: it.sku,
    name: it.name,
    description: it.description,
    unit: it.unit,
    category: it.expand?.category?.name || "",
    current_qty: Number(it.current_qty ?? 0),
    selling_price: Number(it.selling_price ?? 0),
    cost_price: Number(it.cost_price ?? 0),
  }));
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/pos-service.test.ts Expected: PASS (1 test)

  • Step 5: Commit
git add src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts
git commit -m "feat(pos): add listActiveStockCatalog"

Task 4: pos-service.ts — checkoutSale (happy path + validation)

Files:

  • Modify: src/lib/server/pos-service.ts

  • Modify: src/lib/server/pos-service.test.ts

  • Step 1: Write the failing tests (append to the test file)

import { checkoutSale } from "./pos-service";

function stubCheckoutPb(opts: {
  stockItems: Record<string, { current_qty: number; cost_price?: number }>;
}) {
  const created: Record<string, unknown[]> = { pos_sales: [], pos_sale_items: [], stock_ledger: [] };
  const updates: Array<{ collection: string; id: string; data: Record<string, unknown> }> = [];
  let saleCounter = 0;
  let itemCounter = 0;

  return {
    pb: {
      collection: (name: string) => ({
        getFullList: vi.fn(),
        getList: vi.fn(),
        getOne: vi.fn(async (id: string) => {
          const item = opts.stockItems[id];
          if (!item) throw new Error(`not found: ${id}`);
          return { id, current_qty: item.current_qty, cost_price: item.cost_price ?? 0 };
        }),
        create: vi.fn(async (data: Record<string, unknown>) => {
          if (name === "pos_sales") {
            const id = `sale-${++saleCounter}`;
            const row = { id, created: "", updated: "", collectionId: "", collectionName: "pos_sales", ...data };
            created.pos_sales.push(row);
            return row;
          }
          if (name === "pos_sale_items") {
            const id = `sale-item-${++itemCounter}`;
            const row = { id, created: "", updated: "", collectionId: "", collectionName: "pos_sale_items", ...data };
            created.pos_sale_items.push(row);
            return row;
          }
          if (name === "stock_ledger") {
            created.stock_ledger.push(data);
            return { id: "ledger-1", ...data };
          }
          throw new Error(`unexpected create on ${name}`);
        }),
        update: vi.fn(async (id: string, data: Record<string, unknown>) => {
          updates.push({ collection: name, id, data });
          return { id, ...data };
        }),
      }),
    },
    created,
    updates,
  };
}

describe("checkoutSale", () => {
  it("rejects an empty cart", async () => {
    const { pb } = stubCheckoutPb({ stockItems: {} });
    const result = await checkoutSale(pb, { items: [], payment_method: "cash" });
    expect(result).toEqual({ ok: false, reason: "no_items", message: expect.any(String) });
  });

  it("rejects a line with a non-positive qty", async () => {
    const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } } });
    const result = await checkoutSale(pb, {
      items: [{ stock_item_id: "si1", qty: 0, unit_price: 10 }],
      payment_method: "cash",
    });
    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.reason).toBe("invalid_line");
  });

  it("rejects checkout when a line exceeds available stock", async () => {
    const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 2 } } });
    const result = await checkoutSale(pb, {
      items: [{ stock_item_id: "si1", qty: 5, unit_price: 10 }],
      payment_method: "cash",
      cash_given: 50,
    });
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.reason).toBe("insufficient_stock");
      expect(result.shortages).toEqual([{ stock_item_id: "si1", requested: 5, available: 2 }]);
    }
  });

  it("creates the sale, sale items, ledger rows, and decrements stock on a valid cash sale", async () => {
    const { pb, created, updates } = stubCheckoutPb({
      stockItems: { si1: { current_qty: 10, cost_price: 40 } },
    });
    const result = await checkoutSale(pb, {
      items: [{ stock_item_id: "si1", qty: 3, unit_price: 100, sku: "SKU-1", description: "Widget" }],
      payment_method: "cash",
      cash_given: 350,
    });

    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.sale.total).toBe(300);
    expect(result.sale.subtotal).toBeCloseTo(300 / 1.15, 5);
    expect(result.sale.vat).toBeCloseTo(300 - 300 / 1.15, 5);
    expect(result.sale.cash_given).toBe(350);
    expect(result.sale.change_given).toBe(50);
    expect(result.items).toHaveLength(1);
    expect(result.stockErrors).toEqual([]);

    expect(created.stock_ledger).toEqual([
      {
        stock_item: "si1",
        change_qty: -3,
        reason: "customer_fulfillment",
        reference_type: "pos_sale",
        reference_id: result.sale.id,
        notes: "",
        created_by: "",
      },
    ]);
    expect(updates).toEqual([
      { collection: "stock_items", id: "si1", data: { current_qty: 7 } },
    ]);
  });

  it("defaults card sales to cash_given = total with no change", async () => {
    const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } } });
    const result = await checkoutSale(pb, {
      items: [{ stock_item_id: "si1", qty: 1, unit_price: 115 }],
      payment_method: "card",
    });
    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.sale.cash_given).toBe(115);
    expect(result.sale.change_given).toBe(0);
  });
});
  • Step 2: Run tests to verify they fail

Run: npx vitest run src/lib/server/pos-service.test.ts Expected: FAIL — checkoutSale is not exported.

  • Step 3: Add the implementation (append to pos-service.ts)
// ─── checkoutSale ──────────────────────────────────────────────────

export type PosCheckoutLineInput = {
  stock_item_id: string;
  qty: number;
  unit_price: number;
  sku?: string;
  description?: string;
};

export type PosCheckoutInput = {
  items: PosCheckoutLineInput[];
  payment_method: "cash" | "card";
  cash_given?: number;
  customer_id?: string;
};

export type PosCheckoutResult =
  | {
      ok: true;
      sale: PosSale;
      items: PosSaleItem[];
      /** Lines whose ledger/qty write failed after the sale record was created. */
      stockErrors: Array<{ stock_item_id: string; error: string }>;
    }
  | {
      ok: false;
      reason: "no_items" | "invalid_line" | "insufficient_stock" | "pb_error";
      message: string;
      shortages?: Array<{ stock_item_id: string; requested: number; available: number }>;
    };

/**
 * Confirms a POS sale: validates stock availability, creates the
 * `pos_sales`/`pos_sale_items` rows, then for each line writes a
 * `stock_ledger` row and decrements `stock_items.current_qty`.
 *
 * Uses the existing `customer_fulfillment` stock_ledger reason
 * (qty booked out against a customer, from `STOCK_REASON_VALUES`)
 * rather than a new POS-specific reason — `reference_type: "pos_sale"`
 * plus `reference_id: sale.id` is enough to distinguish these rows in
 * the ledger without needing a PB schema change first.
 *
 * If a line's ledger/qty write fails after the sale record already
 * exists, the sale is NOT rolled back — it's reported in
 * `stockErrors` for manual reconciliation, mirroring the
 * compensation-needed pattern in the existing
 * `/api/v1/stock/items/[id]/adjust` route.
 */
export async function checkoutSale(
  pb: PosPb,
  input: PosCheckoutInput,
): Promise<PosCheckoutResult> {
  if (!Array.isArray(input.items) || input.items.length === 0) {
    return { ok: false, reason: "no_items", message: "items must be a non-empty array" };
  }
  for (const line of input.items) {
    if (!line.stock_item_id || !Number.isFinite(line.qty) || line.qty <= 0) {
      return {
        ok: false,
        reason: "invalid_line",
        message: `each item needs a stock_item_id and a positive qty (got ${JSON.stringify(line)})`,
      };
    }
  }

  // 1. Check availability for every line before writing anything.
  const shortages: Array<{ stock_item_id: string; requested: number; available: number }> = [];
  const stockItems = new Map<string, StockItem>();
  for (const line of input.items) {
    const item = (await pb.collection("stock_items").getOne(line.stock_item_id)) as StockItem;
    stockItems.set(line.stock_item_id, item);
    const available = Number(item.current_qty ?? 0);
    if (available < line.qty) {
      shortages.push({ stock_item_id: line.stock_item_id, requested: line.qty, available });
    }
  }
  if (shortages.length > 0) {
    return {
      ok: false,
      reason: "insufficient_stock",
      message: "one or more items do not have enough stock",
      shortages,
    };
  }

  // 2. Totals — VAT-inclusive shelf pricing (matches the existing POS
  // cart's own calc: sub = total / 1.15, vat = total - sub).
  const total = input.items.reduce((sum, l) => sum + l.unit_price * l.qty, 0);
  const subtotal = total / 1.15;
  const vat = total - subtotal;
  const cashGiven = input.payment_method === "cash" ? Number(input.cash_given ?? 0) : total;
  const changeGiven = input.payment_method === "cash" ? Math.max(0, cashGiven - total) : 0;

  // 3. Create the sale header.
  let sale: PosSale;
  try {
    sale = (await pb.collection("pos_sales").create({
      sale_number: "S-" + Date.now().toString(36).toUpperCase(),
      payment_method: input.payment_method,
      cash_given: cashGiven,
      change_given: changeGiven,
      subtotal,
      vat,
      total,
      customer: input.customer_id || "",
    })) as PosSale;
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return { ok: false, reason: "pb_error", message: `Failed to create pos_sales row: ${message}` };
  }

  // 4. Per line: sale item + ledger + qty decrement.
  const items: PosSaleItem[] = [];
  const stockErrors: Array<{ stock_item_id: string; error: string }> = [];
  for (const line of input.items) {
    try {
      const saleItem = (await pb.collection("pos_sale_items").create({
        sale: sale.id,
        stock_item: line.stock_item_id,
        sku: line.sku || "",
        description: line.description || "",
        qty: line.qty,
        unit_cost: stockItems.get(line.stock_item_id)?.cost_price ?? 0,
        unit_price: line.unit_price,
      })) as PosSaleItem;
      items.push(saleItem);

      await pb.collection("stock_ledger").create({
        stock_item: line.stock_item_id,
        change_qty: -line.qty,
        reason: "customer_fulfillment",
        reference_type: "pos_sale",
        reference_id: sale.id,
        notes: "",
        created_by: "",
      });

      const current = stockItems.get(line.stock_item_id)!;
      const nextQty = Number(current.current_qty ?? 0) - line.qty;
      await pb.collection("stock_items").update(line.stock_item_id, { current_qty: nextQty });
    } catch (err) {
      stockErrors.push({
        stock_item_id: line.stock_item_id,
        error: err instanceof Error ? err.message : String(err),
      });
    }
  }

  return { ok: true, sale, items, stockErrors };
}
  • Step 4: Run tests to verify they pass

Run: npx vitest run src/lib/server/pos-service.test.ts Expected: PASS (6 tests)

  • Step 5: Commit
git add src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts
git commit -m "feat(pos): add checkoutSale with stock validation and ledger writes"

Task 5: pos-service.ts — listSalesInRange

Files:

  • Modify: src/lib/server/pos-service.ts

  • Modify: src/lib/server/pos-service.test.ts

  • Step 1: Write the failing test (append)

import { listSalesInRange } from "./pos-service";

describe("listSalesInRange", () => {
  it("fetches sales in the range and their line items in one follow-up call", async () => {
    const salesGetList = vi.fn().mockResolvedValue({
      items: [
        { id: "sale-1", total: 100 },
        { id: "sale-2", total: 200 },
      ],
    });
    const itemsGetList = vi.fn().mockResolvedValue({
      items: [
        { id: "si-1", sale: "sale-1", qty: 2 },
        { id: "si-2", sale: "sale-2", qty: 1 },
      ],
    });
    const pb = {
      collection: (name: string) => {
        if (name === "pos_sales") {
          return { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: salesGetList };
        }
        if (name === "pos_sale_items") {
          return { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: itemsGetList };
        }
        throw new Error(`unexpected collection ${name}`);
      },
    };

    const result = await listSalesInRange(pb, { from: "2026-08-30 00:00:00", to: "2026-08-30 23:59:59" });

    expect(result.sales).toHaveLength(2);
    expect(result.items).toHaveLength(2);
    expect(salesGetList).toHaveBeenCalledWith(1, 500, {
      filter: "created >= '2026-08-30 00:00:00' && created <= '2026-08-30 23:59:59'",
      sort: "-created",
    });
    expect(itemsGetList).toHaveBeenCalledWith(1, 2000, {
      filter: "sale = 'sale-1' || sale = 'sale-2'",
    });
  });

  it("skips the items query when there are no sales in range", async () => {
    const salesGetList = vi.fn().mockResolvedValue({ items: [] });
    const itemsGetList = vi.fn();
    const pb = {
      collection: (name: string) =>
        name === "pos_sales"
          ? { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: salesGetList }
          : { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: itemsGetList },
    };

    const result = await listSalesInRange(pb, { from: "2026-08-30 00:00:00", to: "2026-08-30 23:59:59" });

    expect(result).toEqual({ sales: [], items: [] });
    expect(itemsGetList).not.toHaveBeenCalled();
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/pos-service.test.ts Expected: FAIL — listSalesInRange is not exported.

  • Step 3: Add the implementation (append to pos-service.ts)
// ─── listSalesInRange ───────────────────────────────────────────────

export type PosSalesRangeResult = {
  sales: PosSale[];
  items: PosSaleItem[];
};

/**
 * Sales + their line items for history.html / cashup.html. `from`/`to`
 * are PB datetime filter strings (e.g. "2026-08-30 00:00:00").
 */
export async function listSalesInRange(
  pb: PosPb,
  range: { from: string; to: string },
): Promise<PosSalesRangeResult> {
  const salesPage = await pb.collection("pos_sales").getList<PosSale>(1, 500, {
    filter: `created >= '${range.from}' && created <= '${range.to}'`,
    sort: "-created",
  });
  const sales = salesPage.items;
  if (sales.length === 0) return { sales: [], items: [] };

  const filter = sales.map((s) => `sale = '${s.id}'`).join(" || ");
  const itemsPage = await pb.collection("pos_sale_items").getList<PosSaleItem>(1, 2000, { filter });
  return { sales, items: itemsPage.items };
}
  • Step 4: Run tests to verify they pass

Run: npx vitest run src/lib/server/pos-service.test.ts Expected: PASS (8 tests total)

  • Step 5: Commit
git add src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts
git commit -m "feat(pos): add listSalesInRange for history/cashup"

Task 6: GET /api/v1/pos/catalog

Files:

  • Create: src/app/api/v1/pos/catalog/route.ts

  • Test: src/app/api/v1/pos/catalog/route.test.ts

  • Step 1: Write the failing test

import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";
import { listActiveStockCatalog } from "@/lib/server/pos-service";

vi.mock("@/lib/server/pos-service", () => ({
  listActiveStockCatalog: vi.fn().mockResolvedValue([
    { id: "si1", sku: "SKU-1", name: "Widget", description: "", unit: "ea", category: "", current_qty: 5, selling_price: 10, cost_price: 5 },
  ]),
}));
vi.mock("@/lib/server/pocketbase-admin", () => ({
  getAdminPocketBase: vi.fn().mockResolvedValue({}),
}));

const POS_API_KEY = "test-pos-key";

function catalogRequest(authHeader: string | null = `Bearer ${POS_API_KEY}`): NextRequest {
  const headers: Record<string, string> = {};
  if (authHeader !== null) headers.authorization = authHeader;
  return new NextRequest("http://localhost/api/v1/pos/catalog", { headers });
}

describe("GET /api/v1/pos/catalog", () => {
  beforeEach(() => {
    process.env.POS_API_KEY = POS_API_KEY;
  });

  it("returns the catalog with a totalItems count", async () => {
    const res = await GET(catalogRequest());
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.totalItems).toBe(1);
    expect(body.items[0].sku).toBe("SKU-1");
    expect(listActiveStockCatalog).toHaveBeenCalled();
  });

  it("returns 401 without a valid POS_API_KEY", async () => {
    const res = await GET(catalogRequest("Bearer wrong"));
    expect(res.status).toBe(401);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/app/api/v1/pos/catalog/route.test.ts Expected: FAIL — cannot find module ./route.

  • Step 3: Write the implementation
import { NextRequest, NextResponse } from "next/server";
import { assertPosAuth } from "@/lib/server/pos-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { listActiveStockCatalog } from "@/lib/server/pos-service";

/**
 * GET /api/v1/pos/catalog
 *
 * Active stock_items, flattened for the POS frontend. Requires the
 * POS_API_KEY bearer token — see src/lib/server/pos-auth.ts.
 */
export async function GET(req: NextRequest) {
  try {
    assertPosAuth(req);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unauthorized";
    return NextResponse.json({ error: message }, { status: 401 });
  }

  const pb = await getAdminPocketBase();
  const items = await listActiveStockCatalog(pb);
  return NextResponse.json({ items, totalItems: items.length });
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/app/api/v1/pos/catalog/route.test.ts Expected: PASS (2 tests)

  • Step 5: Commit
git add src/app/api/v1/pos/catalog/route.ts src/app/api/v1/pos/catalog/route.test.ts
git commit -m "feat(pos): add GET /api/v1/pos/catalog"

Task 7: POST /api/v1/pos/checkout

Files:

  • Create: src/app/api/v1/pos/checkout/route.ts

  • Test: src/app/api/v1/pos/checkout/route.test.ts

  • Step 1: Write the failing test

import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "./route";
import { checkoutSale } from "@/lib/server/pos-service";

vi.mock("@/lib/server/pos-service", () => ({
  checkoutSale: vi.fn(),
}));
vi.mock("@/lib/server/pocketbase-admin", () => ({
  getAdminPocketBase: vi.fn().mockResolvedValue({}),
}));

const POS_API_KEY = "test-pos-key";

function checkoutRequest(body: unknown, authHeader: string | null = `Bearer ${POS_API_KEY}`): NextRequest {
  const headers: Record<string, string> = { "content-type": "application/json" };
  if (authHeader !== null) headers.authorization = authHeader;
  return new NextRequest("http://localhost/api/v1/pos/checkout", {
    method: "POST",
    headers,
    body: JSON.stringify(body),
  });
}

describe("POST /api/v1/pos/checkout", () => {
  beforeEach(() => {
    process.env.POS_API_KEY = POS_API_KEY;
    vi.mocked(checkoutSale).mockReset();
  });

  it("returns 200 with the sale on success", async () => {
    vi.mocked(checkoutSale).mockResolvedValue({
      ok: true,
      sale: { id: "sale-1", total: 100 } as never,
      items: [],
      stockErrors: [],
    });
    const res = await POST(checkoutRequest({ items: [{ stock_item_id: "si1", qty: 1, unit_price: 100 }], payment_method: "cash", cash_given: 100 }));
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.ok).toBe(true);
    expect(body.sale.id).toBe("sale-1");
  });

  it("returns 409 when stock is insufficient", async () => {
    vi.mocked(checkoutSale).mockResolvedValue({
      ok: false,
      reason: "insufficient_stock",
      message: "one or more items do not have enough stock",
      shortages: [{ stock_item_id: "si1", requested: 5, available: 2 }],
    });
    const res = await POST(checkoutRequest({ items: [{ stock_item_id: "si1", qty: 5, unit_price: 10 }], payment_method: "cash" }));
    expect(res.status).toBe(409);
  });

  it("returns 400 for other checkout failures", async () => {
    vi.mocked(checkoutSale).mockResolvedValue({ ok: false, reason: "no_items", message: "items must be a non-empty array" });
    const res = await POST(checkoutRequest({ items: [], payment_method: "cash" }));
    expect(res.status).toBe(400);
  });

  it("defaults an unrecognised payment_method to cash", async () => {
    vi.mocked(checkoutSale).mockResolvedValue({ ok: true, sale: { id: "sale-1" } as never, items: [], stockErrors: [] });
    await POST(checkoutRequest({ items: [{ stock_item_id: "si1", qty: 1, unit_price: 10 }], payment_method: "bitcoin" }));
    expect(checkoutSale).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ payment_method: "cash" }));
  });

  it("returns 401 without a valid POS_API_KEY", async () => {
    const res = await POST(checkoutRequest({ items: [] }, "Bearer wrong"));
    expect(res.status).toBe(401);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/app/api/v1/pos/checkout/route.test.ts Expected: FAIL — cannot find module ./route.

  • Step 3: Write the implementation
import { NextRequest, NextResponse } from "next/server";
import { assertPosAuth } from "@/lib/server/pos-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { checkoutSale } from "@/lib/server/pos-service";

/**
 * POST /api/v1/pos/checkout
 *
 * Confirms a POS sale. Body: { items: [{stock_item_id, qty, unit_price,
 * sku?, description?}], payment_method: "cash"|"card", cash_given?,
 * customer_id? }.
 *
 * Returns 200 with `{ ok: true, sale, items, stockErrors }` on
 * success (stockErrors non-empty means the sale was recorded but one
 * or more lines didn't decrement stock — needs manual reconciliation),
 * 409 when stock is insufficient, 400 for other validation failures,
 * 401 for a missing/wrong POS_API_KEY.
 */
export async function POST(req: NextRequest) {
  try {
    assertPosAuth(req);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unauthorized";
    return NextResponse.json({ error: message }, { status: 401 });
  }

  const body = await req.json();
  const pb = await getAdminPocketBase();
  const result = await checkoutSale(pb, {
    items: Array.isArray(body?.items) ? body.items : [],
    payment_method: body?.payment_method === "card" ? "card" : "cash",
    cash_given: body?.cash_given,
    customer_id: body?.customer_id,
  });

  if (!result.ok) {
    const status = result.reason === "insufficient_stock" ? 409 : 400;
    return NextResponse.json(result, { status });
  }
  return NextResponse.json(result);
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/app/api/v1/pos/checkout/route.test.ts Expected: PASS (5 tests)

  • Step 5: Commit
git add src/app/api/v1/pos/checkout/route.ts src/app/api/v1/pos/checkout/route.test.ts
git commit -m "feat(pos): add POST /api/v1/pos/checkout"

Task 8: GET /api/v1/pos/sales

Files:

  • Create: src/app/api/v1/pos/sales/route.ts

  • Test: src/app/api/v1/pos/sales/route.test.ts

  • Step 1: Write the failing test

import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";
import { listSalesInRange } from "@/lib/server/pos-service";

vi.mock("@/lib/server/pos-service", () => ({
  listSalesInRange: vi.fn().mockResolvedValue({ sales: [{ id: "sale-1" }], items: [] }),
}));
vi.mock("@/lib/server/pocketbase-admin", () => ({
  getAdminPocketBase: vi.fn().mockResolvedValue({}),
}));

const POS_API_KEY = "test-pos-key";

function salesRequest(query = "", authHeader: string | null = `Bearer ${POS_API_KEY}`): NextRequest {
  const headers: Record<string, string> = {};
  if (authHeader !== null) headers.authorization = authHeader;
  return new NextRequest(`http://localhost/api/v1/pos/sales${query}`, { headers });
}

describe("GET /api/v1/pos/sales", () => {
  beforeEach(() => {
    process.env.POS_API_KEY = POS_API_KEY;
    vi.mocked(listSalesInRange).mockClear();
  });

  it("passes explicit from/to through to the service", async () => {
    const res = await GET(salesRequest("?from=2026-08-01+00:00:00&to=2026-08-01+23:59:59"));
    expect(res.status).toBe(200);
    expect(listSalesInRange).toHaveBeenCalledWith(expect.anything(), {
      from: "2026-08-01 00:00:00",
      to: "2026-08-01 23:59:59",
    });
  });

  it("defaults to today's date range when from/to are omitted", async () => {
    await GET(salesRequest());
    const call = vi.mocked(listSalesInRange).mock.calls[0][1];
    expect(call.from).toMatch(/^\d{4}-\d{2}-\d{2} 00:00:00$/);
    expect(call.to).toMatch(/^\d{4}-\d{2}-\d{2} 23:59:59$/);
  });

  it("returns 401 without a valid POS_API_KEY", async () => {
    const res = await GET(salesRequest("", "Bearer wrong"));
    expect(res.status).toBe(401);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/app/api/v1/pos/sales/route.test.ts Expected: FAIL — cannot find module ./route.

  • Step 3: Write the implementation
import { NextRequest, NextResponse } from "next/server";
import { assertPosAuth } from "@/lib/server/pos-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { listSalesInRange } from "@/lib/server/pos-service";

/**
 * GET /api/v1/pos/sales?from=&to=
 *
 * Sales + line items in a date range, for history.html/cashup.html.
 * `from`/`to` are PB datetime filter strings (space, not "T" —
 * matches PB's own `created` format); both default to today.
 */
export async function GET(req: NextRequest) {
  try {
    assertPosAuth(req);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unauthorized";
    return NextResponse.json({ error: message }, { status: 401 });
  }

  const { searchParams } = new URL(req.url);
  const today = new Date().toISOString().slice(0, 10);
  const from = searchParams.get("from") || `${today} 00:00:00`;
  const to = searchParams.get("to") || `${today} 23:59:59`;

  const pb = await getAdminPocketBase();
  const result = await listSalesInRange(pb, { from, to });
  return NextResponse.json(result);
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/app/api/v1/pos/sales/route.test.ts Expected: PASS (3 tests)

  • Step 5: Commit
git add src/app/api/v1/pos/sales/route.ts src/app/api/v1/pos/sales/route.test.ts
git commit -m "feat(pos): add GET /api/v1/pos/sales"

Task 9: Run the full backend test suite

  • Step 1: Run the full suite to confirm no regressions

Run: npx vitest run Expected: PASS — all existing tests plus the 4 new POS test files (pos-auth, pos-service, catalog route, checkout route, sales route) green.

  • Step 2: Typecheck the whole project

Run: npx tsc --noEmit Expected: no errors.

(No commit — this is a verification checkpoint. If either command fails, fix before moving to Part 2.)


Part 2 — Frontend (relocate + rewire the existing mockup)

The mockup already implements catalog browsing, cart, cash/card payment UI, an offline sale queue, and Bluetooth thermal printing. These tasks move it into this repo and repoint every PocketBase call at the new /api/v1/pos/* routes instead.

Task 10: Copy the mockup into public/pos/

Files:

  • Create: public/pos/index.html, public/pos/pos.html, public/pos/receipt.html, public/pos/history.html, public/pos/cashup.html, public/pos/pos-app.js, public/pos/theme.js, public/pos/sw.js, public/pos/manifest.json

  • Step 1: Create the directory and copy the static files (everything except pb-config.js and the .artifact.json/.od-skills tooling metadata)

mkdir -p public/pos
SRC="/Volumes/KIOXIA/Library/Application Support/Open Design/namespaces/release-stable/data/projects/9147f587-4959-4659-a6ba-a11f629a783c"
cp "$SRC/index.html" "$SRC/pos.html" "$SRC/receipt.html" "$SRC/history.html" "$SRC/cashup.html" "$SRC/pos-app.js" "$SRC/theme.js" "$SRC/sw.js" "$SRC/manifest.json" public/pos/
  • Step 2: Verify pb-config.js was not copied

Run: ls public/pos/ Expected: the 9 files listed above, no pb-config.js, no .artifact.json files.

  • Step 3: Commit the raw copy (subsequent tasks rewire it — keeping this as its own commit makes the diffs in later tasks readable)
git add public/pos/
git commit -m "chore(pos): copy WFSALES POS mockup into public/pos/ (unwired)"

Task 11: Replace pb-config.js with pos-config.js (no PocketBase credentials)

Files:

  • Create: public/pos/pos-config.js

  • Modify: public/pos/index.html, public/pos/pos.html, public/pos/receipt.html, public/pos/history.html, public/pos/cashup.html (each has a <script src="pb-config.js"></script> tag to update)

  • Step 1: Create pos-config.js

// POS config — no PocketBase credentials here. The device only knows
// its own API key for this app's /api/v1/pos/* routes; PocketBase
// auth happens server-side (see src/lib/server/pocketbase-admin.ts).
window.POS_CONFIG = (function () {
  const STORAGE_KEY = "pos.apiKey";
  let apiKey = localStorage.getItem(STORAGE_KEY) || "";
  if (!apiKey) {
    apiKey = window.prompt("Enter this till's POS API key (ask the office for it):") || "";
    if (apiKey) localStorage.setItem(STORAGE_KEY, apiKey);
  }
  return { apiBase: "/api/v1/pos", apiKey };
})();

window.posFetch = async function (path, opts = {}) {
  const cfg = window.POS_CONFIG;
  const headers = Object.assign(
    { Authorization: "Bearer " + cfg.apiKey },
    opts.headers || {},
  );
  return fetch(cfg.apiBase + path, Object.assign({}, opts, { headers }));
};
  • Step 2: Swap the script tag in every HTML file

In public/pos/index.html, public/pos/pos.html, public/pos/receipt.html, public/pos/history.html, public/pos/cashup.html, replace:

<script src="pb-config.js"></script>

with:

<script src="pos-config.js"></script>
  • Step 3: Commit
git add public/pos/pos-config.js public/pos/index.html public/pos/pos.html public/pos/receipt.html public/pos/history.html public/pos/cashup.html
git commit -m "feat(pos): replace hardcoded PB superuser creds with POS_API_KEY config"

Task 12: Rewire pos-app.js catalog loading

Files:

  • Modify: public/pos/pos-app.js

  • Step 1: Replace loadProducts() and loadLetterhead()

Replace this block (originally lines 76-121 — the exact line numbers will differ slightly after Task 11's edits, match by content):

async function loadProducts(){
  // Try IDB first
  try{
    const cached = await dbAll(STORE_PRODUCTS);
    if(cached.length){
      state.products = cached;
      state.productsById = new Map(cached.map(p=>[p.id,p]));
      buildCategories();
      render();
    }
  }catch{}
  // Then network refresh
  try{
    await window.pbAuth();
    const all = [];
    let page = 1;
    while(true){
      const r = await window.pbFetch(`/api/collections/${cfg.collections.products}/records?perPage=200&page=${page}&sort=description`);
      const j = await r.json();
      all.push(...j.items);
      if(j.page >= j.totalPages) break;
      page++;
      if(page > 30) break; // hard cap
    }
    state.products = all;
    state.productsById = new Map(all.map(p=>[p.id,p]));
    buildCategories();
    render();
    // Persist
    for(const p of all) await dbPut(STORE_PRODUCTS, p);
  }catch(e){
    console.warn('Network load failed', e);
    if(!state.products.length){
      const el = document.getElementById('empty');
      el.innerHTML = '<h3>Offline</h3><p>No cached catalog. Reconnect and reload.</p>';
    }
  }
}

async function loadLetterhead(){
  try{
    const r = await window.pbFetch(`/api/collections/${cfg.collections.letterhead}/records?perPage=1&filter=is_active=true`);
    const j = await r.json();
    if(j.items && j.items[0]) state.letterhead = j.items[0];
  }catch{}
}

with:

async function loadProducts(){
  // Try IDB first
  try{
    const cached = await dbAll(STORE_PRODUCTS);
    if(cached.length){
      state.products = cached;
      state.productsById = new Map(cached.map(p=>[p.id,p]));
      buildCategories();
      render();
    }
  }catch{}
  // Then network refresh — single call, the API route returns the
  // full active catalog (no pagination needed at this catalog size).
  try{
    const r = await window.posFetch('/catalog');
    const j = await r.json();
    const all = (j.items || []).map(p => ({
      ...p,
      description: p.description || p.name,
    }));
    state.products = all;
    state.productsById = new Map(all.map(p=>[p.id,p]));
    buildCategories();
    render();
    for(const p of all) await dbPut(STORE_PRODUCTS, p);
  }catch(e){
    console.warn('Network load failed', e);
    if(!state.products.length){
      const el = document.getElementById('empty');
      el.innerHTML = '<h3>Offline</h3><p>No cached catalog. Reconnect and reload.</p>';
    }
  }
}

async function loadLetterhead(){
  // Letterhead comes from the main app in a later task if needed;
  // the receipt already falls back to "WFSALES POS" when absent.
}
  • Step 2: Commit
git add public/pos/pos-app.js
git commit -m "feat(pos): load catalog from /api/v1/pos/catalog instead of PB directly"

Task 13: Rewire pos-app.js checkout and offline queue

Files:

  • Modify: public/pos/pos-app.js

  • Step 1: Replace confirmSale()

Replace the whole function (originally lines 454-547 — locate by the async function confirmSale() signature) with:

async function confirmSale(){
  const btn = document.getElementById('confirmBtn');
  if(btn.disabled) return;
  btn.disabled = true; btn.innerHTML = 'Saving…';
  const items = Array.from(state.cart.values()).map(({p,qty})=>({
    stock_item_id: p.id, qty, unit_price: p.selling_price ?? p.cost_price ?? 0,
    sku: p.sku, description: p.description,
  }));
  const totals = cartTotals();
  const checkoutBody = {
    items,
    payment_method: state.paymentMethod,
    cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
    customer_id: state.customer ? state.customer.id : undefined,
  };

  const sale = {
    localId: uid(),
    createdAt: new Date().toISOString(),
    payment: state.paymentMethod,
    cashGiven: state.cashGiven || null,
    change: state.paymentMethod==='cash' ? Math.max(0, state.cashGiven - totals.total) : 0,
    sub: totals.sub, vat: totals.vat, total: totals.total,
    items: Array.from(state.cart.values()).map(({p,qty})=>({
      sku: p.sku, description: p.description, unit: p.unit,
      cost_price: p.cost_price, selling_price: p.selling_price, qty, product_id: p.id,
    })),
    customer: state.customer || null,
    synced: false,
  };

  try{
    const r = await window.posFetch('/checkout', {
      method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(checkoutBody),
    });
    if(r.ok){
      const j = await r.json();
      sale.synced = true;
      sale.serverRef = j.sale && j.sale.id;
    } else {
      throw new Error('server returned ' + r.status);
    }
  }catch(e){
    console.warn('Sale queued for sync', e);
    try{
      await dbPut(STORE_QUEUE, {...sale, checkoutBody});
    }catch(qErr){
      console.error('IndexedDB queue failed too — saving to localStorage as fallback', qErr);
      try{
        const arr = JSON.parse(localStorage.getItem('wfsales.queue') || '[]');
        arr.push({...sale, checkoutBody});
        localStorage.setItem('wfsales.queue', JSON.stringify(arr));
      }catch(_){}
    }
  }

  // ALWAYS navigate to receipt — never leave the user stuck on "Saving…"
  try{ sessionStorage.setItem('pos.lastSale', JSON.stringify(sale)); }catch(_){}
  state.cart.clear(); persistCart(); paintCart(); render();
  closePayment();
  setTimeout(()=>{ location.href = 'receipt.html?id=' + encodeURIComponent(sale.localId); }, 60);
}
  • Step 2: Replace flushQueue()

Replace the function (originally lines 558-577) with:

async function flushQueue(){
  if(!navigator.onLine) return;
  const q = await dbAll(STORE_QUEUE);
  for(const s of q){
    try{
      const r = await window.posFetch('/checkout', {
        method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s.checkoutBody),
      });
      if(r.ok){
        await dbDel(STORE_QUEUE, s.localId);
      } else {
        break; // stop on first failure, retry next time we come online
      }
    }catch(e){ console.warn('queue item failed', e); break; }
  }
}
  • Step 3: Commit
git add public/pos/pos-app.js
git commit -m "feat(pos): checkout via /api/v1/pos/checkout, drop orders/order_items/delivery_notes writes"

Task 14: Rewire index.html boot script

Files:

  • Modify: public/pos/index.html

  • Step 1: Replace the boot <script> block

Replace:

<script>
(async function(){
  const cfg = window.PB_CONFIG;
  // Letterhead (subline only — the app name is fixed; letterhead supplies the trading name)
  try{
    const r = await window.pbFetch('/api/collections/letterhead_settings/records?perPage=1&filter=is_active=true');
    const j = await r.json();
    if(j.items && j.items[0]){
      const lh = j.items[0];
      // Append the trading name to the subline, never replace the app name
      if(lh.address) document.getElementById('brandSub').textContent = lh.address.split('\n')[0];
    }
  }catch{}
  // Product count
  try{
    const r = await window.pbFetch('/api/collections/pos/records?perPage=1');
    const j = await r.json();
    document.getElementById('prodCount').textContent = j.totalItems?.toLocaleString() || '—';
  }catch{}
  // Cache state
  try{
    const c = await caches.open('pos-v1');
    const keys = await c.keys();
    document.getElementById('cacheState').textContent = keys.length ? 'ready' : 'cold';
  }catch{ document.getElementById('cacheState').textContent = '—'; }
  // Network pill
  function paintNet(){
    const on = navigator.onLine;
    const p = document.getElementById('netPill');
    const l = document.getElementById('netLabel');
    p.classList.toggle('offline', !on);
    l.textContent = on ? 'Online' : 'Offline';
    const s = document.getElementById('statusMsg');
    s.textContent = on ? 'Connected to PocketBase' : 'Offline — sales will sync when you reconnect';
  }
  window.addEventListener('online', paintNet);
  window.addEventListener('offline', paintNet);
  paintNet();
  if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(()=>{});
})();
</script>

with:

<script>
(async function(){
  // Product count via the same catalog endpoint pos.html uses.
  try{
    const r = await window.posFetch('/catalog');
    const j = await r.json();
    document.getElementById('prodCount').textContent = (j.totalItems||0).toLocaleString();
  }catch{}
  // Cache state
  try{
    const c = await caches.open('pos-v1');
    const keys = await c.keys();
    document.getElementById('cacheState').textContent = keys.length ? 'ready' : 'cold';
  }catch{ document.getElementById('cacheState').textContent = '—'; }
  // Network pill
  function paintNet(){
    const on = navigator.onLine;
    const p = document.getElementById('netPill');
    const l = document.getElementById('netLabel');
    p.classList.toggle('offline', !on);
    l.textContent = on ? 'Online' : 'Offline';
    const s = document.getElementById('statusMsg');
    s.textContent = on ? 'Connected' : 'Offline — sales will sync when you reconnect';
  }
  window.addEventListener('online', paintNet);
  window.addEventListener('offline', paintNet);
  paintNet();
  if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(()=>{});
})();
</script>

(The letterhead lookup is dropped — it required direct PB access from the client. If a trading-name subline is wanted later, expose it via a small addition to the /catalog response, not a new direct PB call.)

  • Step 2: Commit
git add public/pos/index.html
git commit -m "feat(pos): rewire index.html product count to /api/v1/pos/catalog"

Task 15: Rewire history.html

Files:

  • Modify: public/pos/history.html

  • Step 1: Replace the load() function

Replace:

async function load(){
  const list = document.getElementById('list');
  list.innerHTML = '<div class="empty"><h3>Loading…</h3></div>';
  document.getElementById('kpiCount').textContent = '—';
  document.getElementById('kpiTotal').textContent = 'R 0';
  const start = activeDay + ' 00:00:00';
  const end = activeDay + ' 23:59:59';
  const filter = `created >= '${start}' && created <= '${end}'`;
  try{
    await window.pbAuth();
    const r = await window.pbFetch(`/api/collections/${cfg.collections.orders}/records?perPage=200&sort=-created&filter=${encodeURIComponent(filter)}`);
    const j = await r.json();
    const rows = [];
    let grandTotal = 0;
    for(const o of j.items){
      const li = await window.pbFetch(`/api/collections/${cfg.collections.orderItems}/records?perPage=200&filter=order='${o.id}'`);
      const liJ = await li.json();
      const items = liJ.items || [];
      const total = items.reduce((s,it)=> s + (it.selling_price||0) * (it.quantity||0), 0);
      grandTotal += total;
      const isAccount = /ACCOUNT/.test(o.notes||'');
      const method = isAccount ? 'Account' : (/EFT/.test(o.notes||'')?'EFT':(/CARD/.test(o.notes||'')?'Card':'Cash'));
      const dt = new Date(o.created);
      rows.push({o, items, total, method, dt});
    }
    document.getElementById('kpiCount').textContent = j.items.length;
    document.getElementById('kpiTotal').textContent = fmt(grandTotal);
    if(!rows.length){
      list.innerHTML = '<div class="empty"><h3>No sales</h3><p>No sales recorded on this day.</p></div>';
      return;
    }
    list.innerHTML = rows.map(r=>{
      const dt = r.dt.toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'});
      const mClass = r.method.toLowerCase();
      return `<button class="sale-row" data-od-id="sale-${r.o.id}" onclick="reprint('${r.o.id}')">
        <div class="nm">
          <b>${esc(r.o.order_number||r.o.id.slice(-6))}</b>
          <small>${dt} · ${r.items.length} item${r.items.length===1?'':'s'}</small>
        </div>
        <span class="method ${mClass}">${esc(r.method)}</span>
        <div class="pr">${fmt(r.total)}</div>
      </button>`;
    }).join('');
  }catch(e){
    list.innerHTML = '<div class="empty"><h3>Offline</h3><p>Could not reach the register. Reconnect to view today\'s sales.</p></div>';
  }
}

with:

async function load(){
  const list = document.getElementById('list');
  list.innerHTML = '<div class="empty"><h3>Loading…</h3></div>';
  document.getElementById('kpiCount').textContent = '—';
  document.getElementById('kpiTotal').textContent = 'R 0';
  const from = activeDay + ' 00:00:00';
  const to = activeDay + ' 23:59:59';
  try{
    const r = await window.posFetch(`/sales?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
    const j = await r.json();
    const sales = j.sales || [];
    const itemsBySale = new Map();
    for(const it of (j.items||[])){
      if(!itemsBySale.has(it.sale)) itemsBySale.set(it.sale, []);
      itemsBySale.get(it.sale).push(it);
    }
    const rows = sales.map(s => ({
      s, items: itemsBySale.get(s.id)||[], total: s.total,
      method: s.payment_method==='card' ? 'Card' : 'Cash',
      dt: new Date(s.created),
    }));
    const grandTotal = rows.reduce((sum,r)=>sum+r.total,0);
    document.getElementById('kpiCount').textContent = sales.length;
    document.getElementById('kpiTotal').textContent = fmt(grandTotal);
    if(!rows.length){
      list.innerHTML = '<div class="empty"><h3>No sales</h3><p>No sales recorded on this day.</p></div>';
      return;
    }
    list.innerHTML = rows.map(r=>{
      const dt = r.dt.toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'});
      const mClass = r.method.toLowerCase();
      return `<button class="sale-row" data-od-id="sale-${r.s.id}" onclick="reprint('${r.s.id}')">
        <div class="nm">
          <b>${esc(r.s.sale_number||r.s.id.slice(-6))}</b>
          <small>${dt} · ${r.items.length} item${r.items.length===1?'':'s'}</small>
        </div>
        <span class="method ${mClass}">${esc(r.method)}</span>
        <div class="pr">${fmt(r.total)}</div>
      </button>`;
    }).join('');
  }catch(e){
    list.innerHTML = '<div class="empty"><h3>Offline</h3><p>Could not reach the register. Reconnect to view today\'s sales.</p></div>';
  }
}
  • Step 2: Commit
git add public/pos/history.html
git commit -m "feat(pos): rewire history.html to /api/v1/pos/sales"

Task 16: Rewire cashup.html

Files:

  • Modify: public/pos/cashup.html

  • Step 1: Find the equivalent fetch block (mirrors history.html's pattern — locate the two window.pbFetch calls at what were originally lines 351 and 356)

Replace:

const r = await window.pbFetch(`/api/collections/${cfg.collections.orders}/records?perPage=500&filter=${encodeURIComponent(`created >= '${start}' && created <= '${end}'`)}`);

and its accompanying per-order orderItems loop (same shape as the block removed in Task 15) with a single call:

const r = await window.posFetch(`/sales?from=${encodeURIComponent(start)}&to=${encodeURIComponent(end)}`);
const j = await r.json();
const sales = j.sales || [];
const itemsBySale = new Map();
for(const it of (j.items||[])){
  if(!itemsBySale.has(it.sale)) itemsBySale.set(it.sale, []);
  itemsBySale.get(it.sale).push(it);
}

Then update every downstream reference from the old orders/order_items field names (selling_price/quantity on order items, notes-based payment-method sniffing) to the pos_sales/pos_sale_items fields: sale.payment_method (already "cash"/"card", no regex needed), sale.total/sale.cash_given/sale.change_given for the cash-drawer math, and item.unit_price/item.qty from itemsBySale.get(sale.id).

  • Step 2: Commit
git add public/pos/cashup.html
git commit -m "feat(pos): rewire cashup.html to /api/v1/pos/sales"

Task 17: receipt.html — customer's company name + price toggle on the delivery slip

Files:

  • Modify: public/pos/receipt.html

  • Modify: public/pos/pos-app.js (customer search needs to expand company)

  • Step 1: In pos-app.js, expand company when searching customers

Find searchCustomers and change the fetch line from:

const r = await window.pbFetch(`/api/collections/${cfg.collections.customers}/records?perPage=8&filter=${encodeURIComponent(f)}`);

This direct-PB customer search must also move behind the backend — add it to the checkout route's surface instead of a raw PB call. Replace the whole searchCustomers function with:

async function searchCustomers(q){
  const box = document.getElementById('custResults');
  if(!q){ box.innerHTML=''; return; }
  try{
    const r = await window.posFetch(`/customers?q=${encodeURIComponent(q)}`);
    const j = await r.json();
    box.innerHTML = (j.items||[]).map(c=>`<div data-id="${c.id}" data-name="${escapeAttr(c.name)}" data-company="${escapeAttr(c.company||'')}">
      <span>${escapeHTML(c.name)}</span>
      <small>${escapeHTML(c.company || c.number || c.email || '')}</small>
    </div>`).join('');
    box.querySelectorAll('div').forEach(d=>{
      d.addEventListener('click',()=>{
        state.customer = {id:d.dataset.id, name:d.dataset.name, company:d.dataset.company};
        const pk = document.getElementById('custPicked');
        pk.classList.add('show');
        pk.querySelector('b').textContent = d.dataset.name;
        document.getElementById('custQ').value = '';
        box.innerHTML = '';
        validateConfirm();
      });
    });
  }catch{}
}
  • Step 2: Add a GET /api/v1/pos/customers route + listCustomersForPos service function (small, follows the same pattern as Tasks 3/6 — no separate TDD walkthrough needed given how closely it mirrors listActiveStockCatalog/catalog/route.ts)

src/lib/server/pos-service.ts — append:

export type PosCustomerResult = { id: string; name: string; company: string };

export async function searchCustomersForPos(pb: PosPb, query: string): Promise<PosCustomerResult[]> {
  if (!query.trim()) return [];
  const safe = query.replace(/'/g, "\\'");
  const rows = await pb.collection("customers").getFullList<{
    id: string;
    customer: string;
    number?: string;
    email?: string;
    expand?: { company?: { company?: string } };
  }>({
    filter: `customer ~ '${safe}' || number ~ '${safe}' || email ~ '${safe}'`,
    expand: "company",
    sort: "customer",
  });
  return rows.slice(0, 8).map((c) => ({
    id: c.id,
    name: c.customer || c.email || c.number || "—",
    company: c.expand?.company?.company || "",
  }));
}

src/app/api/v1/pos/customers/route.ts — create:

import { NextRequest, NextResponse } from "next/server";
import { assertPosAuth } from "@/lib/server/pos-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { searchCustomersForPos } from "@/lib/server/pos-service";

/** GET /api/v1/pos/customers?q= — customer picker for account sales. */
export async function GET(req: NextRequest) {
  try {
    assertPosAuth(req);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unauthorized";
    return NextResponse.json({ error: message }, { status: 401 });
  }
  const { searchParams } = new URL(req.url);
  const q = searchParams.get("q") || "";
  const pb = await getAdminPocketBase();
  const items = await searchCustomersForPos(pb, q);
  return NextResponse.json({ items });
}

src/lib/server/pos-service.test.ts — append:

import { searchCustomersForPos } from "./pos-service";

describe("searchCustomersForPos", () => {
  it("returns an empty array for a blank query without calling PB", async () => {
    const getFullList = vi.fn();
    const pb = { collection: () => ({ getFullList, getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: vi.fn() }) };
    const result = await searchCustomersForPos(pb, "  ");
    expect(result).toEqual([]);
    expect(getFullList).not.toHaveBeenCalled();
  });

  it("maps customer rows to {id, name, company}", async () => {
    const pb = {
      collection: () => ({
        getFullList: vi.fn().mockResolvedValue([
          { id: "c1", customer: "Jane Doe", expand: { company: { company: "Acme Ltd" } } },
        ]),
        getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: vi.fn(),
      }),
    };
    const result = await searchCustomersForPos(pb, "jane");
    expect(result).toEqual([{ id: "c1", name: "Jane Doe", company: "Acme Ltd" }]);
  });
});

Run: npx vitest run src/lib/server/pos-service.test.ts src/app/api/v1/pos/customers Expected: PASS

  • Step 3: In pos-app.js, carry company onto the sale object — find confirmSale() (edited in Task 13) and confirm state.customer (which now includes company) flows into sale.customer unchanged — no further edit needed since the whole object is already assigned.

  • Step 4: In receipt.html, show the company name and add a price toggle to the delivery note

Replace renderDeliveryNote:

function renderDeliveryNote(s){
  const lh = s.letterhead || {};
  const date = new Date(s.createdAt);
  const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
  const showPrices = !!document.getElementById('dnShowPrices')?.checked;
  const lines = s.items.map(it=>{
    const price = showPrices ? `<div class="pr">${fmt((it.selling_price||0)*it.qty)}</div>` : '';
    return `<div class="dn-line">
      <div class="nm">${esc(it.description||it.sku)}</div>
      <div class="qt">${it.qty} ${esc(it.unit||'ea')}</div>
      ${price}
    </div>`;
  }).join('');
  const companyLine = s.customer?.company ? `<div class="v">${esc(s.customer.company)}</div>` : '';
  const html = `
    <div class="dn-head">
      <div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
      <div class="nm">
        <div class="t">Delivery note</div>
        <div class="s">${esc(s.customer?.name || '—')}</div>
      </div>
    </div>
    <div class="dn-meta">
      <div><div class="k">Customer</div><div class="v">${esc(s.customer?.name||'—')}</div>${companyLine}</div>
      <div><div class="k">Date</div><div class="v">${dateStr}</div></div>
      <div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
      ${showPrices ? `<div><div class="k">Sale total</div><div class="v">${fmt(s.total)}</div></div>` : ''}
    </div>
    <label class="dn-toggle"><input type="checkbox" id="dnShowPrices" ${showPrices?'checked':''} onchange="renderDeliveryNote(window.__lastSale)"> Include prices</label>
    <div class="dn-items">${lines}</div>
    <div class="dn-sign"><div>Received by</div><div>Driver</div></div>
  `;
  document.getElementById('deliveryCard').innerHTML = html;
}

And where renderDeliveryNote(sale) is first called (in the if(sale.payment==='account') block), stash the sale for the toggle's re-render:

if(sale.payment==='account'){
  window.__lastSale = sale;
  document.getElementById('deliveryWrap').style.display='block';
  document.getElementById('printDnBtn').style.display='flex';
  renderDeliveryNote(sale);
}
  • Step 5: Add minimal CSS for the new toggle — in the <style> block, near the existing .dn rules, add:
.dn-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-soft);margin:10px 0;padding:0 2px}
.dn-toggle input{width:16px;height:16px}
  • Step 6: Commit
git add public/pos/receipt.html public/pos/pos-app.js src/lib/server/pos-service.ts src/lib/server/pos-service.test.ts src/app/api/v1/pos/customers/route.ts
git commit -m "feat(pos): delivery-note slip shows company name and optional prices; move customer search behind /api/v1/pos/customers"

Task 18: sw.js — cache list + version bump

Files:

  • Modify: public/pos/sw.js

  • Step 1: Update APP_SHELL and bump CACHE

Replace:

const CACHE = "pos-v12";
const APP_SHELL = [
  './',
  './index.html',
  './pos.html',
  './receipt.html',
  './history.html',
  './cashup.html',
  './pb-config.js',
  './pos-app.js',
  './theme.js',
  './manifest.json'
];

with:

const CACHE = "pos-v13";
const APP_SHELL = [
  './',
  './index.html',
  './pos.html',
  './receipt.html',
  './history.html',
  './cashup.html',
  './pos-config.js',
  './pos-app.js',
  './theme.js',
  './manifest.json'
];
  • Step 2: Update the "never cache" hostname check — the existing rule skips caching for pocketbase/sslip.io hostnames, which no longer matters since the frontend calls same-origin /api/v1/pos/* now. Replace:
  // Never cache PocketBase API
  if(url.hostname.includes('pocketbase') || url.hostname.includes('sslip.io')) return;

with:

  // Never cache API responses — only the static app shell.
  if(url.pathname.startsWith('/api/')) return;
  • Step 3: Commit
git add public/pos/sw.js
git commit -m "chore(pos): update service worker cache list and skip /api/ from caching"

Task 19: manifest.json — scope to /pos/

Files:

  • Modify: public/pos/manifest.json

  • Step 1: Set start_url and scope to the app's real path (they're currently relative, written for a root-hosted deploy; now the app lives under /pos/)

Change:

  "start_url": "index.html",
  "scope": ".",

to:

  "start_url": "/pos/index.html",
  "scope": "/pos/",
  • Step 2: Commit
git add public/pos/manifest.json
git commit -m "chore(pos): scope PWA manifest to /pos/"

Task 20: Manual end-to-end verification

Not automatable (this is a static PWA with no existing test harness in this repo, plus it depends on physical hardware — Bluetooth printer, card machine). Do this against a dev server once the PB admin prerequisite (top of this plan) is done and POS_API_KEY/PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD are set in .env.local:

  • Run npm run dev, open http://localhost:3000/pos/index.html (or the configured dev port), enter the POS API key when prompted.
  • Confirm the catalog loads real stock_items (check a known SKU's price/qty matches the main app's Stock page).
  • Add items to cart, pay cash with change due — confirm the receipt shows the correct change, and that the sold item's current_qty decreased in the main app's Stock page.
  • Repeat with card payment — confirm no change is shown.
  • Pick an existing customer (with a company set), confirm the receipt's delivery-note slip shows the company name; toggle "Include prices" and confirm the per-line and total prices appear/disappear.
  • Turn off network (devtools offline), make a sale, confirm it still reaches the receipt screen and shows "Saved locally"; turn network back on and confirm flushQueue() syncs it (check pos_sales in PB admin).
  • Open history.html and cashup.html, confirm today's sales appear with correct totals.
  • If a Bluetooth thermal printer is available, confirm Print slip and Print delivery note both produce readable output.

Plan self-review notes

  • Spec coverage: superuser-credential removal (Tasks 10-11), stock_items/stock_ledger-backed catalog and checkout (Tasks 3-4, 6-7, 12-13), pos_sales/pos_sale_items data model (manual prerequisite + Task 4), account-sale delivery slip without a new workflow record (Task 17), history/cashup off pos_sales (Tasks 15-16), POS_API_KEY auth (Task 2, wired into every route) — all covered.
  • Deviation from spec, called out explicitly at the top: customer_fulfillment reused instead of a new pos_sale ledger reason, removing a manual PB-admin step. customers search also moved behind a new /api/v1/pos/customers route (Task 17) — the spec didn't call this out explicitly, but it was a direct-PB call in the original mockup that would otherwise still need the removed superuser credentials; folding it into the POS API surface was necessary to actually satisfy "no PocketBase credentials shipped to the client."
  • Letterhead branding on the receipt is intentionally dropped rather than wired up — it's cosmetic, wasn't asked for in the design's goals, and would need another new endpoint; noted inline in Task 14 as a place to extend later if wanted.

Implementation notes (2026-08-31)

Implemented on branch feat/wfsales-pos. Two deliberate deviations from the plan as written, both agreed with the user before any code was written:

  1. All four tender types, not two. pos.html offers Cash, Card, EFT and Account. The plan's backend accepted only cash/card and coerced everything else to cash, which would have booked account (pay-later) and EFT sales as money in the drawer. PosPaymentMethod is now cash | card | eft | account; only cash records cash_given/ change_given, the rest record zero. Stock decrements for all four — including account, since the goods physically leave on a delivery note. cashup.html already rendered four method rows, so under the original plan the EFT and Account rows would have been permanently R0.

  2. Checkout is idempotent via client_ref. The plan queued a sale on any checkout failure, including a request the server had already committed but whose response was lost. flushQueue() would then re-post it, duplicating the sale and decrementing stock twice — contradicting the design's "a sale is never double-decremented". The till now sends its local sale id as client_ref; checkoutSale returns the existing record with duplicate: true and performs no writes when that key is already present.

Smaller adjustments made along the way:

  • searchCustomersForPos + GET /api/v1/pos/customers landed with the other routes (Task 8) rather than inside Task 17, since it is backend work.
  • The delivery-note price toggle drives the Bluetooth thermal print as well as the on-screen slip, and the printed slip now carries the company name.
  • index.html distinguishes a rejected API key from being offline, instead of showing "Offline" for both.
  • PB filter strings built by hand are quote-escaped (escapePbValue).

Verification status

  • 35 POS unit/route tests pass; tsc --noEmit clean for all POS files.
  • Pre-existing suite failures (9 files) were confirmed identical at the merge base — this branch introduces none.
  • Live smoke test against the dev server: all 9 static files serve from /pos/*, all four endpoints 401 without a valid key, and /catalog + /customers return real PocketBase data with one.

PocketBase collections — created 2026-08-31

Created directly against the live instance via the superuser API (the user asked for this explicitly; AGENTS.md's "user does it in PB admin" convention was waived for this one-off). Script kept at /tmp/pb-create-pos.mjs, not committed — the repo still runs no migrations against the live instance.

  • pos_sales (pbc_4138824761) — all API rules null (superuser-only). Fields: created/updated autodate, sale_number, payment_method (select: cash/card/eft/account), cash_given, change_given, subtotal, vat, total, customer (relation → customers), client_ref. Indexes: idx_pos_sales_created, and a partial unique index idx_pos_sales_client_ref ... WHERE client_ref != '' — a plain UNIQUE would have rejected every sale after the first that carried an empty client_ref.
  • pos_sale_items (pbc_1600525088) — all API rules null. Fields: created/updated autodate, sale (relation → pos_sales, required, cascadeDelete), stock_item (relation → stock_items, required), sku, description, qty, unit_cost, unit_price. Index on sale.

Live end-to-end results (dev server, real PocketBase)

  • Cash sale of 3 units: sale written, VAT split correct (R300 → R260.87 + R39.13), change R50, current_qty 7 → 4, one ledger row.
  • Replay of the same client_ref: returned the original sale with duplicate: true, wrote nothing, current_qty stayed 4. Idempotency holds.
  • account and eft sales: recorded under their own method with cash_given 0, customer relation populated, stock still decremented.
  • Over-selling (999 of 7): HTTP 409 with per-line shortage detail.
  • All test rows deleted and current_qty restored to 7 afterwards.

Still blocked before the till can take real money

  • No sellable stock. Of 373 active stock_items, zero have a selling_price and zero have a markup; only 98 have a cost_price. The POS prices from selling_price, so every line currently rings up at R0.00. The user opted to populate selling_price in the main app rather than add a code-side fallback.
  • Bluetooth thermal printing is still unverified — needs the physical printer.

Catalog paging (added 2026-08-31, beyond the original plan)

The till was rendering all 373 items at once. listActiveStockCatalog now sorts -created and PosCatalogItem carries created; the frontend renders 50 at a time behind a Show-more button that also auto-loads on scroll. Header shows "Recently added" while browsing, "Results" once filtered; changing the search or category resets to the first chunk. Scroll position survives re-renders so adding to the cart no longer jumps to the top.