feat(store): overhaul into a shop that can also install — design tokens, catalog v2, cart with WhatsApp checkout #39

Merged
yuris merged 8 commits from store-overhaul into main 2026-08-24 10:50:31 +00:00
Owner

Overhauls apps/store from a workshop landing page with a product strip into a shop whose differentiator is that it can also install what it sells, ships parts nationwide from Situbondo, and takes special-part orders. No payment gateway, no accounts, no server — the site stays a static export.

Closes #33, #34, #35, #36, #37, #38.

What landed

Step Change
#33 Design token system in globals.css — spacing/colour/radius tokens, retail type scale, symmetric rhythm, denser grid, a 1024px breakpoint for the previously unhandled 701–1120px range
#34 packages/catalog schema v2 — price, brand, partNumber, category, weightGrams, inStock, shippable, installService. Clean cutover, no v1 branch
#35 apps/management produces v2 — six inventory columns + migration, getPublicCatalog mapping, STOREFRONT_COLUMNS, validators, actions, MCP tools, stok UI
#36 apps/store consumes v2 — price-led cards, formatIdr, Product/Offer JSON-LD, category-derived filters
#37 Shop-first IA — hero search, category tiles, homepage reorder, shop-led metadata
#38 localStorage cart + WhatsApp checkout, and /cara-pesan

Verification

@garage/catalog 10/10 · garage 51/51 · garage-store 15/15 · build 19 routes · store lint clean.

Driven in a browser against the static export, not just unit-tested:

  • Homepage order by offset: hero 140 → tiles 730 → grid 988 → service rail 1812 → <picture> 2270 → photos 3072. The workshop photo is out of the hero.
  • Hero search oli → /products?q=oli, input pre-filled, grid filtered. Filters render Semua · Kaki-kaki · Oli, derived from catalog data.
  • Priced product: Rp 450.000, Ready, brand + part number, resolved install link, Product JSON-LD with Offer/IDR/InStock.
  • Cart: qty-2 install line + a second line → badge 2, survived a full page load, mixed-shipping warning, checkout disabled until a destination is typed. Decoded wa.me text matched the spec exactly, including [pasang di tempat] on only the install line.
  • Out-of-stock: Habis + .is-out, add button disabled, availability WhatsApp link instead, and no Offer emitted for a null price.

Consequences to review deliberately

Orders now trigger store rebuilds. sellPrice and stock joined STOREFRONT_COLUMNS, because price and availability are on the public site now. Every stock-decrementing order queues a build; the workflow's cancel-in-progress group collapses bursts. buyPrice/avgCost still do not trigger one.

sellPrice === 0 maps to price: null ("belum ditentukan"), matching the existing bot-tool convention. A genuinely free item cannot be expressed.

category is free text, and homepage tiles are derived from the distinct values present, so an operator typo creates a stray tile.

Bug caught only by a browser check

AddToCart initially called whatsappUrl({kind:"product"}) client-side. That resolves siteUrl(), which reads the build-only SITE_URL and throws in the browser; React's hydration retry loop then crashed the renderer on every out-of-stock product page — while curl served the page fine, so tests and a build check both passed it. Fixed by computing the URL server-side and passing askUrl as a prop.

product-browser.tsx is a client component importing the same helper. It only uses kind: "general" today, which is safe, but it is a live landmine.

Not addressed

pnpm --filter garage lint still fails on 35 pre-existing biome errors in untouched components/ui/* shadcn files. It was 37 before this branch. Deliberately left: auto-formatting 177 files would bury the real diff.

Overhauls `apps/store` from a workshop landing page with a product strip into a shop whose differentiator is that it can also install what it sells, ships parts nationwide from Situbondo, and takes special-part orders. No payment gateway, no accounts, no server — the site stays a static export. Closes #33, #34, #35, #36, #37, #38. ## What landed | Step | Change | |---|---| | #33 | Design token system in `globals.css` — spacing/colour/radius tokens, retail type scale, symmetric rhythm, denser grid, a `1024px` breakpoint for the previously unhandled 701–1120px range | | #34 | `packages/catalog` schema v2 — `price`, `brand`, `partNumber`, `category`, `weightGrams`, `inStock`, `shippable`, `installService`. Clean cutover, no v1 branch | | #35 | `apps/management` produces v2 — six inventory columns + migration, `getPublicCatalog` mapping, `STOREFRONT_COLUMNS`, validators, actions, MCP tools, stok UI | | #36 | `apps/store` consumes v2 — price-led cards, `formatIdr`, `Product`/`Offer` JSON-LD, category-derived filters | | #37 | Shop-first IA — hero search, category tiles, homepage reorder, shop-led metadata | | #38 | `localStorage` cart + WhatsApp checkout, and `/cara-pesan` | ## Verification `@garage/catalog` 10/10 · `garage` 51/51 · `garage-store` 15/15 · build 19 routes · store lint clean. Driven in a browser against the static export, not just unit-tested: - Homepage order by offset: hero 140 → tiles 730 → grid 988 → service rail 1812 → `<picture>` 2270 → photos 3072. The workshop photo is out of the hero. - Hero search `oli` → `/products?q=oli`, input pre-filled, grid filtered. Filters render `Semua · Kaki-kaki · Oli`, derived from catalog data. - Priced product: `Rp 450.000`, `Ready`, brand + part number, resolved install link, `Product` JSON-LD with `Offer`/`IDR`/`InStock`. - Cart: qty-2 install line + a second line → badge 2, survived a full page load, mixed-shipping warning, checkout disabled until a destination is typed. Decoded `wa.me` text matched the spec exactly, including `[pasang di tempat]` on only the install line. - Out-of-stock: `Habis` + `.is-out`, add button disabled, availability WhatsApp link instead, and no `Offer` emitted for a null price. ## Consequences to review deliberately **Orders now trigger store rebuilds.** `sellPrice` and `stock` joined `STOREFRONT_COLUMNS`, because price and availability are on the public site now. Every stock-decrementing order queues a build; the workflow's `cancel-in-progress` group collapses bursts. `buyPrice`/`avgCost` still do not trigger one. **`sellPrice === 0` maps to `price: null`** ("belum ditentukan"), matching the existing bot-tool convention. A genuinely free item cannot be expressed. **`category` is free text**, and homepage tiles are derived from the distinct values present, so an operator typo creates a stray tile. ## Bug caught only by a browser check `AddToCart` initially called `whatsappUrl({kind:"product"})` client-side. That resolves `siteUrl()`, which reads the build-only `SITE_URL` and throws in the browser; React's hydration retry loop then crashed the renderer on every out-of-stock product page — while `curl` served the page fine, so tests and a build check both passed it. Fixed by computing the URL server-side and passing `askUrl` as a prop. `product-browser.tsx` is a client component importing the same helper. It only uses `kind: "general"` today, which is safe, but it is a live landmine. ## Not addressed `pnpm --filter garage lint` still fails on 35 pre-existing biome errors in untouched `components/ui/*` shadcn files. It was 37 before this branch. Deliberately left: auto-formatting 177 files would bury the real diff.
Two blockers found in review of #39:

- Orders, purchases, and stock adjustments wrote inventory.stock directly
  and never called requestStoreDeploy, so the new public availability badge
  went stale on every sale. Adding "stock" to STOREFRONT_COLUMNS bought
  nothing for the case it was added for. All ten transaction wrappers that
  move stock now trigger a rebuild after commit.
- An empty or negative value the write paths accept (category "",
  sellPrice < 0, weightGrams <= 0) failed PublicCatalogSchema.parse for the
  WHOLE payload, 500ing the catalog API and breaking every storefront build
  until someone found the offending row. Normalised at the producer, the one
  boundary that owns the contract, and the validators now match it.

Also: /cart no longer paints its empty state before the localStorage read
lands, clearing the quantity field no longer deletes the line, a newline in
a product name can no longer forge rows in the WhatsApp message, and the
inert avgCost test case is replaced with a real column.
Author
Owner

Review actioned — 560ba64

Two reviewers ran over this: a general quality pass and a read-only security pass. Both independently landed on the same producer/validator gap, which is the strongest signal in the whole review. Every finding is fixed.

P1 — stale availability (the one that mattered)

The branch documented and tested "stock changes rebuild the store", but requestStoreDeploy was only ever called from lib/inventory/actions.ts and lib/mcp/tools/inventory.ts. The paths that actually move stock — orders/service.ts:127, purchases/service.ts:49,114,160, stock-movements/service.ts:38 — write the column directly inside a transaction and never triggered a deploy. Verified with a grep for callsites before fixing.

So adding "stock" to STOREFRONT_COLUMNS bought nothing for the case it was added for: selling the last unit left the live site advertising Ready indefinitely. The branch shipped the opposite of the tradeoff it documented.

All ten transaction wrappers that move stock now fire after commit — five server actions and five MCP tools. Deliberately after the transaction, not inside it: a rollback should not queue a build, and a network call has no business holding a DB transaction open.

P1 — one bad row took the whole catalog down

category: "", sellPrice < 0, and weightGrams <= 0 all pass the inbound validators and persist, then fail PublicCatalogSchema.parse for the entire payload. ?? "Lainnya" only caught null. Result: /api/public/catalog 500s, next build fails, and the storefront can never be redeployed until someone finds the offending row — while the live site stays green, so the symptom surfaces in CI far from the cause.

Fixed at the producer, the single boundary that already owns the contract, rather than by tightening four validator schemas:

category: row.category?.trim() || "Lainnya",
price: row.sellPrice > 0 ? row.sellPrice : null,
weightGrams: row.weightGrams && row.weightGrams > 0 ? row.weightGrams : null,

weight_grams and sell_price in validators.ts were also tightened to match the contract — the reviewer noticed the MCP schema already had this right and the REST path was the laxer one, which is the opposite of the usual expectation.

This is now a real regression test, not a decorative one. I stashed the fix and confirmed the new test fails — and that one bad row fails three tests, because the whole payload throws:

× derives price, availability, and category from the inventory row
× normalises out-of-contract values instead of rejecting the whole catalog
× never emits cost, sell price, or a raw stock count
Tests  3 failed | 49 passed (52)

P2 — two client bugs, both browser-verified

  • /cart painted "Keranjang masih kosong" on every visit. The localStorage read is in useEffect, which runs after paint, so a customer with items was told their cart was empty for a frame. Fixed with a ready flag off the module-level loaded value, which keeps the hydration-match guarantee. Proved with a MutationObserver installed before navigation: emptyStateEverPainted: false with a full cart, and the empty state still renders for a genuinely empty one.
  • Clearing the quantity field deleted the line. Number("") === 0 and setQty treats <= 0 as removal, so emptying the field to retype destroyed the line. Guarded at the caller, since the − button is the deliberate path to zero. Verified: clear → both lines survive; retype 3 → total Rp 1.445.000; and − on a qty-1 line still removes it.

P3

Newline in a product name could forge rows in the admin's WhatsApp message (including a fake total) — collapsed whitespace in the name and destination, left the note alone since newlines are legitimate there. Two tests added. Also replaced the inert avgCost test case (not a column, so no bug could ever break it) with createdAt, fixed an eslint-disable comment in a biome repo that was silently doing nothing, and corrected the cart badge's aria-label to say jenis barang, since it counts lines rather than units by design.

Declined

The security pass flagged that a tampered localStorage cart can show its owner a false price. Not fixed: writing that key requires already executing script on this origin, so the only victim is the tamperer, and nothing is authorised on the strength of the cart — the admin confirms price out of band, which the UI states. Rated informational by the reviewer too.

Cleared by security, with evidence

Data exposure (buyPrice/avgCost/supplier/raw stock cannot reach the payload — three stacked controls), the wa.me deep link (one encodeURIComponent over the whole message; the phone number is a literal), XSS via the JSON-LD block (the </script> escape is global), and a re-audit for more instances of the build-only-env-in-client-code bug — none found.

Final state

@garage/catalog 10/10 · garage 52/52 · garage-store 17/17 · build 19 routes · store lint clean. Management biome errors 37 → 32 (all remaining are pre-existing components/ui shadcn debt, untouched on purpose).

## Review actioned — `560ba64` Two reviewers ran over this: a general quality pass and a read-only security pass. Both independently landed on the same producer/validator gap, which is the strongest signal in the whole review. Every finding is fixed. ### P1 — stale availability (the one that mattered) The branch documented and tested "stock changes rebuild the store", but `requestStoreDeploy` was only ever called from `lib/inventory/actions.ts` and `lib/mcp/tools/inventory.ts`. The paths that actually move stock — `orders/service.ts:127`, `purchases/service.ts:49,114,160`, `stock-movements/service.ts:38` — write the column directly inside a transaction and never triggered a deploy. Verified with a grep for callsites before fixing. So adding `"stock"` to `STOREFRONT_COLUMNS` bought nothing for the case it was added for: selling the last unit left the live site advertising `Ready` indefinitely. **The branch shipped the opposite of the tradeoff it documented.** All ten transaction wrappers that move stock now fire after commit — five server actions and five MCP tools. Deliberately after the transaction, not inside it: a rollback should not queue a build, and a network call has no business holding a DB transaction open. ### P1 — one bad row took the whole catalog down `category: ""`, `sellPrice < 0`, and `weightGrams <= 0` all pass the inbound validators and persist, then fail `PublicCatalogSchema.parse` for the **entire** payload. `?? "Lainnya"` only caught `null`. Result: `/api/public/catalog` 500s, `next build` fails, and the storefront can never be redeployed until someone finds the offending row — while the live site stays green, so the symptom surfaces in CI far from the cause. Fixed at the producer, the single boundary that already owns the contract, rather than by tightening four validator schemas: ```ts category: row.category?.trim() || "Lainnya", price: row.sellPrice > 0 ? row.sellPrice : null, weightGrams: row.weightGrams && row.weightGrams > 0 ? row.weightGrams : null, ``` `weight_grams` and `sell_price` in `validators.ts` were also tightened to match the contract — the reviewer noticed the MCP schema already had this right and the REST path was the laxer one, which is the opposite of the usual expectation. **This is now a real regression test, not a decorative one.** I stashed the fix and confirmed the new test fails — and that one bad row fails *three* tests, because the whole payload throws: ``` × derives price, availability, and category from the inventory row × normalises out-of-contract values instead of rejecting the whole catalog × never emits cost, sell price, or a raw stock count Tests 3 failed | 49 passed (52) ``` ### P2 — two client bugs, both browser-verified - **`/cart` painted "Keranjang masih kosong" on every visit.** The `localStorage` read is in `useEffect`, which runs after paint, so a customer with items was told their cart was empty for a frame. Fixed with a `ready` flag off the module-level `loaded` value, which keeps the hydration-match guarantee. Proved with a `MutationObserver` installed before navigation: `emptyStateEverPainted: false` with a full cart, and the empty state still renders for a genuinely empty one. - **Clearing the quantity field deleted the line.** `Number("") === 0` and `setQty` treats `<= 0` as removal, so emptying the field to retype destroyed the line. Guarded at the caller, since the `−` button is the deliberate path to zero. Verified: clear → both lines survive; retype `3` → total `Rp 1.445.000`; and `−` on a qty-1 line still removes it. ### P3 Newline in a product name could forge rows in the admin's WhatsApp message (including a fake total) — collapsed whitespace in the name and destination, left the note alone since newlines are legitimate there. Two tests added. Also replaced the inert `avgCost` test case (not a column, so no bug could ever break it) with `createdAt`, fixed an `eslint-disable` comment in a biome repo that was silently doing nothing, and corrected the cart badge's `aria-label` to say `jenis barang`, since it counts lines rather than units by design. ### Declined The security pass flagged that a tampered `localStorage` cart can show its owner a false price. Not fixed: writing that key requires already executing script on this origin, so the only victim is the tamperer, and nothing is authorised on the strength of the cart — the admin confirms price out of band, which the UI states. Rated informational by the reviewer too. ### Cleared by security, with evidence Data exposure (`buyPrice`/`avgCost`/supplier/raw stock cannot reach the payload — three stacked controls), the `wa.me` deep link (one `encodeURIComponent` over the whole message; the phone number is a literal), XSS via the JSON-LD block (the `</script>` escape is global), and a re-audit for more instances of the build-only-env-in-client-code bug — none found. ### Final state `@garage/catalog` 10/10 · `garage` 52/52 · `garage-store` 17/17 · build 19 routes · store lint clean. Management biome errors 37 → 32 (all remaining are pre-existing `components/ui` shadcn debt, untouched on purpose).
yuris merged commit 9bc810c50f into main 2026-08-24 10:50:31 +00:00
yuris deleted branch store-overhaul 2026-08-24 10:50:31 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/garage!39
No description provided.