One entry per phase: what was built, key decisions, problems solved.
Built
- Root files:
.gitignore,.gitattributes(LF),.editorconfig,.nvmrc(Node 24),docker-compose.yml(Postgres dev 5432, test 5433 on tmpfs),.env.examplefor API and web, MITLICENSE, VS Code recommendations. - GitHub conventions:
CODEOWNERS, PR template with a tenant/money/i18n checklist, issue forms (bug, feature),CONTRIBUTING.md(branches, Conventional Commits, scopes). - Docs:
CLAUDE.md(rules and commands),README.md,PLAN.md,ARCHITECTURE.md,DATA_MODEL.md,API.md.
Key decisions
- Name: Booth (working name).
- Scope widened beyond Latin America: US/EU payment presets (Zelle, Venmo, Cash App, Bizum, MB WAY), an optional payment link on any transfer method so stores can take cards through their own provider, an optional prices/tax note, English in the MVP, privacy defaults per country.
- Money stored as
BigIntminor units to fit large-denomination currencies (COP, IDR) in order totals. - Branching:
developis the default integration branch (short-lived<type>/<desc>branches, rebase merge so every commit is kept);mainonly receives completed versions through release PRs and is tagged. - CI workflow, Husky and commitlint are created in Phase 0 together with
package.json, so the first CI run has real scripts to execute. - Frontend: Radix primitives + Motion for micro-interactions, animated SVG + lazy Lottie for vector animations, OKLCH-derived store theme, strict small-component rules.
Problems solved
- None yet.
Built
- pnpm workspace with
apps/api,apps/webandpackages/shared; Node 24, pnpm 11.28.6 pinned inpackageManager. - Strict TypeScript base, ESLint 10 flat config (type-checked typescript-eslint, import order,
@eslint-react, react-hooks, jsx-a11y-x), Prettier with Tailwind class sorting. - Husky hooks: lint-staged on pre-commit, commitlint with the project's scopes on commit-msg.
@booth/shared(Zod, dual ESM/CJS build with tsup),@booth/api(NestJS 12 + SWC,GET /health, Jest + Supertest),@booth/web(Vite 8, React 19, Tailwind 4, Vitest + Testing Library).- GitHub: public repository,
developas default branch, rulesets fordevelop(rebase merge only) andmain(merge commits from release PRs only), both requiring theciandpr-titlechecks; labels, secret scanning with push protection, Dependabot alerts and updates targetingdevelop. - CI: format → lint → typecheck → test (Postgres 17 service) → build.
Key decisions
- TypeScript 6.0.3, not 7. TypeScript 7 is the latest release, but typescript-eslint (<6.1), ts-jest (<7) and the Nest CLI (bundles 6.0) do not support it yet. Dependabot ignores TypeScript major updates until they do.
- pnpm 11, not 12. pnpm 12 is a native (Rust) rewrite; the automatic version switch from pnpm 11 failed on Windows because the native binary was not linked. pnpm 11.28.6 is the mature line.
@swc/jestinstead of ts-jest for API tests: no TypeScript version coupling, same SWC config as the build.- No Turborepo.
pnpm -ralready builds in dependency order; rootlint,typecheckandtestbuild@booth/sharedfirst so type-aware tools see its declarations. - ESLint 10 with plugins that support it (
@eslint-react,eslint-plugin-jsx-a11y-x) instead of downgrading to ESLint 9 for the oldereslint-plugin-react/jsx-a11y.
Problems solved
- Nest 12 is ESM-only, Jest runs CommonJS. Jest failed with "Must use import to load ES Module". Node 24 supports
require(esm), and Jest uses it when run with--experimental-vm-modules, so the API test script runs Jest through Node with that flag. The API stays CommonJS (decorator metadata via SWC). npm installinstalled packages beforeonly-allowrejected it, leavingnode_modulesand apackage-lock.json. Unsatisfiableengines.npm/engines.yarnwithengine-strict=truemake npm and yarn fail before writing anything;package-lock.jsonandyarn.lockare also gitignored.- Squash merge hid the progressive history. PR #1 was first squash-merged, collapsing its 8 commits into one on
develop. Because the tree was identical,developwas reset to the original 8 commits (rule paused for the force-push, then re-enabled) anddevelopnow allows only rebase merges. - A heredoc dropped a backslash in the Jest transform regex (
'^.+\.ts$'became "any character before ts"). ESLint'sno-useless-escapecaught it; the pattern now usesString.raw.
Built
@booth/shared: money helpers (ISO 4217 exponents, exact string-basedtoMinor/fromMinor,formatMoneywithIntl.NumberFormat), E.164 phone helpers (libphonenumber-js), country profiles (defaults for 22 countries, generic fallback), app locales, time zone validation, the API error contract and the health schema.- Config: Zod-validated environment, typed
AppConfig, fails before Nest starts with a list of every invalid variable. - Prisma 7 with the PostgreSQL driver adapter; full MVP schema and the first migration, plus CHECK constraints Prisma cannot express (non-negative stock and amounts, valid business hours, positive quantities).
/healthchecks the database (200 / 503), sits outside the/apiprefix and is never rate limited.- Global
/apiprefix, Swagger at/api/docs, Helmet, CORS allowlist with credentials,trust proxy. - One error format for everything (
AppException+AllExceptionsFilter), request ids in headers, error bodies and logs. - Zod validation pipe and decorators shared with the web forms.
- Rate limiting: default bucket plus opt-in
authandpublic-ordersbuckets, keyed by the real client IP. - Structured logging with pino (pretty in development, redacted auth headers).
- Test harness: separate test database, migrations applied once per run,
createTestApp()with the production HTTP setup,resetDatabase().
Key decisions
- Own Zod pipe instead of
nestjs-zod: it does not support Nest 12 yet. Zod 4'sz.toJSONSchemacovers Swagger, so the replacement is ~40 lines. - Prisma 7.10.0, not 7.10.1: 7.10.1 was published the same day, and pnpm 11's
minimumReleaseAgepolicy (supply-chain protection) rejects packages younger than a day. The policy stays on; pnpm had auto-added exclusions, which were removed. - Money:
toMinorparses the decimal string instead of multiplying floats (0.1 + 0.2→ 30 cents).formatMoneyhands Intl a decimal string so large amounts stay exact, and hides a zero fraction by default ("₡3 500"). - ISO 4217 over CLDR for minor units, with an explicit table for the 0-, 3- and 4-decimal currencies.
- Rate-limit storage in memory: correct for a single Render instance; documented as a prerequisite to change before scaling out.
@scarf/scarfblocked inallowBuilds: it is install-time telemetry pulled in by Swagger UI.
Problems solved
- A long shell heredoc broke on quoting; source files are now written with the editor tool, not shell heredocs.
- A commit accidentally included files staged earlier and had a type error that the pre-commit hook (lint only) did not catch. It was undone before pushing and redone as two commits.
exactOptionalPropertyTypesrejecteddatasource: { url: undefined }inprisma.config.ts; the datasource is now only added whenDATABASE_URLis set, soprisma generatestill works without a database.- Swagger UI was checked in a real browser to confirm Helmet's default CSP does not break it.
Built
- Register, login, refresh, logout and
/me. Argon2id password hashes, 15-minute HS256 access tokens, opaque refresh tokens in anHttpOnly; Secure; SameSite=Lax; Path=/api/authcookie. - Refresh rotation with token families: replaying a rotated token revokes the family. Rotation uses a conditional update so concurrent refreshes cannot both succeed.
- Global
JwtAuthGuard(secure by default,@Public()to opt out),@CurrentUser(),@CurrentStore()(store id only from the authenticated user), globalDemoReadOnlyGuard. - Onboarding:
POST /api/me/storewith country-profile defaults, slug rules (pattern, length, reserved words), slug availability check, one store per owner. TenantResolverchain withPathTenantResolver,TenantGuardand@CurrentTenant()for public routes; a subdomain resolver plugs into the same list.- Shared Zod schemas for auth, sessions, store onboarding and
slugifyfor the web forms.
Key decisions
- Opaque refresh tokens instead of JWTs: they are looked up in the database anyway (rotation, revocation), so a signature adds nothing; only a SHA-256 hash is stored.
JWT_REFRESH_SECRETwas dropped from the env. - Load the user on every authenticated request instead of trusting token claims: the store id appears after onboarding and
isDemocan change, and it costs one indexed query. @node-rs/argon2overargon2: prebuilt binaries, no install script to allow.- Production refuses the example JWT secret (
change-me…), on top of the 32-character minimum. - Same error message for "wrong password" and "unknown email", plus a dummy-hash verification, so the login endpoint does not reveal registered emails.
Problems solved
- The
express-serve-static-coremodule augmentation failed under pnpm's strict layout (the package is not a direct dependency);req.auth/req.tenantnow augmentexpress'sRequestinstead. - commitlint rejected the new
storesscope, and the failed commit's staged files slipped into the next commit. The local commit was undone,storeswas added to commitlint, the PR-title check and CONTRIBUTING, and the commits were redone. - GitHub's rebase merge rewrites SHAs, so the Phase 2 branch (cut from the unmerged Phase 1 branch) was rebased onto
develop; git skipped the already-merged commits automatically.
Built
- Categories: create, update, delete (products become uncategorized), reorder, product counts.
- Products: CRUD with cursor pagination, name search and category filter,
trackStock/stock, reorder. Prices are integer minor units (BigIntin the database, JSON numbers in the API). - Product images (up to 8, reorder, delete) and store logo and cover, stored through a
MediaStorageinterface: Cloudinary in production, a disabled storage that answers503 MEDIA_NOT_CONFIGUREDwithout credentials, a fake in tests. - Store settings: name, description, WhatsApp phone, brand color with an automatic readable foreground, prices note.
- Upload validation by magic bytes (JPEG, PNG, WebP, AVIF), 5 MB limit, metadata stripped on Cloudinary.
- A tenant isolation suite: store B tries every owner catalog endpoint against store A's ids and always gets
404, and A's data stays untouched. docs/CREDENTIALS_SETUP.es.md: a Spanish guide for the owner on finding and setting Cloudinary and deployment credentials (written in Spanish at the owner's request).
Key decisions
- Readable foreground instead of rejecting brand colors: any color is allowed and the API returns black or white text for it. The guarantee (WCAG AA, worst case about 4.58:1) holds only with pure black; a 4,096-color test enforces it.
- "Validated against the store currency": an integer amount of minor units is valid for every currency (the exponent only affects display), so validation is "non-negative safe integer". Changing a store's currency is deliberately not possible from settings, because it would change the meaning of existing prices; that is handled in Phase 6.
- The API returns finished image URLs (320/800/1600 px, automatic format and quality), so the web app does not depend on Cloudinary.
- 404 for other stores' ids (never 403) so ids cannot be probed; a foreign
categoryIdin a product body is a400field error. - SVG uploads are rejected: they can carry scripts.
definedOnly()helper: Zod partial schemas producefield?: T | undefined, which Prisma rejects underexactOptionalPropertyTypes; the helper drops undefined keys and keepsnull(which means "clear the field").
Problems solved
- The first contrast test only sampled grays (stepping by
0x0f0f0fkeeps R, G and B equal) and the first foreground (#111111) fell to 4.42:1 on some mid-tones. Both were fixed: the test walks the RGB cube and the dark foreground is#000000. - Prettier un-escaped
\|inside a Markdown table cell inAPI.md, breaking the table; the cell was reworded without pipes. - A commit command used a lockfile path relative to the wrong directory, so nothing was committed; it was rerun from the repository root.
Built
- Public storefront API (no auth, store resolved from the slug): store info with open/closed status in the store time zone, pickup and delivery zones, payment methods, default privacy notice per country; catalog with availability; server-side quote.
- Checkout: the order is saved as
PENDINGwith item snapshots and reserved stock before WhatsApp opens. Totals, delivery fee and payment label are computed and snapshotted on the server. - Stock reservation in one transaction with conditional updates in product-id order; release adds reserved quantities back and clears the
stockReservedflags in the same transaction. - Expiration: lazy (before checkout and when the tracking page is opened), every 5 minutes in-process (
@nestjs/schedule), andPOST /api/internal/jobs/expire-ordersprotected byJOB_SECRETfor an external cron. A status-guardedUPDATE … RETURNINGmakes it exactly-once under concurrency. - Localized WhatsApp message (store language and currency) and
wa.melink; short Crockford base32 order codes; 256-bit tracking tokens. - Tracking page API and receipt upload (JPEG, PNG, WebP, HEIC, PDF) to private Cloudinary storage; a receipt sets
RECEIPT_SUBMITTEDand pauses expiration. - Shared contracts: business hours (time-zone aware), privacy notices (es/en, citing each country's law), order, quote, tracking and public store schemas.
Key decisions
- A store without business hours is always open, so a new store can sell immediately.
- Payment choice is
{ type: 'CASH' }or{ type: 'TRANSFER', methodId }instead of a barepaymentMethodId: cash is a store toggle, not a payment method row. - Concurrency is tested for real: two parallel orders for the last unit (exactly one succeeds) and 12 parallel orders against 5 units (exactly 5 succeed), repeated runs stable.
- Tracking never exposes the customer phone; the token is the only key, the order code is just a payment reference.
- Receipts are stored as private ("authenticated") assets; PDFs use the image resource type so one signed-URL path serves every format. The Spanish guide explains enabling PDF delivery in Cloudinary.
- Hidden categories do not hide their products: they move to
uncategorized, so an owner hiding a category does not silently stop sales. - The unused
ORDER_EXPIRATION_MINUTESenv variable was removed: the window is a per-store setting.
Deferred to Phase 6 (by design)
- Cancelling orders and the owner order list. Both reuse
StockService.releaseandOrderExpirationService.expireStale, which are already tested here.
Problems solved
- A closed-store test only asserted inside
if (!openNow), so it could pass without checking anything. It now sets hours on a weekday that is never "today" in the store time zone, so the assertions always run. - Two long shell heredocs failed on quoting again; scripted edits are now written as files in the scratchpad and run with Node.
Built
- Design system: warm neutral tokens, light and dark themes, Tailwind 4 theme mapped to CSS variables, per-store brand color with OKLCH-derived shades and an automatic readable foreground.
- Small UI primitives (one per file): Button with press feedback, Field with accessible wiring, Sheet (draggable bottom sheet on phones, side panel on desktop), animated Checkbox, OptionCards with a gliding selection ring, QuantityStepper with rolling numbers, Badge, Skeleton, EmptyState, CopyButton.
- Vector animations: drawn checkmarks, cart bag bump, timeline fill, pulsing "open now" dot, hand-made animated SVG illustrations.
- Storefront (
/s/:slug): cover and logo, open/closed badge with next opening, sticky scroll-spy category tabs, product cards and detail sheet, per-store persisted cart, floating cart bar. - Checkout (
/s/:slug/checkout): contact (local phone normalized to E.164 with the store's country), pickup or delivery with zone fees, cash or transfer, privacy consent with the store's notice, live server quote. - Tracking (
/t/:token): "order saved" screen with the WhatsApp button, expiry countdown, animated status timeline, transfer details with copy buttons and the order code as reference, receipt upload with a progress bar. - i18n (es default, en), API wake-up notice, error and empty states, per-store SEO and Open Graph tags,
robots.txt.
Key decisions
- WhatsApp as a link the customer taps, not an automatic popup: browsers block
window.openafter an async request, and the order is already saved, so the tracking page leads with "Send order on WhatsApp" and the store page links back to the latest order. - Animated SVG instead of Lottie: no Lottie assets existed, and SVG illustrations inherit the store color and add no runtime.
- First-load performance work (measured with Lighthouse): prefetch store data from
index.html, lazy routes, lazy Motion features (LazyMotion+m.*), lazy toasts, and no Zod on the storefront's first load.@booth/sharednow builds one file per module and keeps schemas apart from helpers. Main bundle 277 KB → 180 KB gzipped. - Storefront responses are typed, not validated at runtime; checkout and tracking still validate. The API's e2e tests guard the public contract.
- Contrast tokens were darkened (success, danger, subtle text) after Lighthouse flagged 3.08:1 and 3.49:1.
Lighthouse (mobile, local production build): Accessibility 100, Best Practices 100, SEO 100, Performance 72–80. The largest paint (~3.5 s) is bound by JS execution under 4× CPU throttling; the target of 90 is re-checked on the deployed build in Phase 7, with HTML pre-rendering of the store page as the next step if needed.
Problems solved (found by testing in the browser)
- Buttons inside sheets never received clicks: the whole sheet was draggable, and Motion captures the pointer on press, so
clicklanded on the sheet. Only the handle and header start a drag now (useDragControls). - Sheets and toasts ignored the store color: they render in portals outside the themed wrapper. The theme is applied to
<html>while a store page is mounted. - The cover overlapped the logo: the header content needed its own stacking context.
- Derived brand shades did not update when
--brandwas overridden on a child: custom properties resolve where declared, so the shades are re-declared on every[data-brand]element. - The strict React lint rules caught
Date.now()during render, setState in an effect,forwardRef,cloneElementandform.watch(incompatible with the React Compiler); replaced withuseNow, cleanup-based state, ref-as-prop, a render-propFieldanduseWatch.
Built
- Store settings: ordering options (accept orders while closed, expiration window, pickup with address, delivery, cash), localization (country, currency, time zone, language) and the privacy notice.
- Transfer payment methods with presets for 13 methods across CR, MX, CO, PE, BR, US, ES, PT and anywhere (PayPal), fully custom methods and an optional https payment link.
- Delivery zones and weekly business hours (split shifts, overlap check, empty week = always open).
- Owner orders: list with status, payment, date and text filters (cursor pagination), detail with timeline, status flow, mark paid / unpaid, reactivation, signed receipt URLs.
Key decisions
- Currency lock: prices are minor units of the store currency, so the currency only changes while the store has no products or orders (
canChangeCurrencytells the dashboard). - Privacy notice versioning: a new text bumps
privacyNoticeVersion; each order stores the version the customer accepted. - Status flow in
@booth/shared(ORDER_TRANSITIONS), so the dashboard only offers valid actions and the API enforces the same table. - Expire before acting: status changes run lazy expiry first, so an order whose window passed cannot be confirmed; updates are conditional on the status that was read, so concurrent changes cannot both apply.
- Paid orders never expire; cancelling releases stock through the same exactly-once release used by expiry.
- Cash stays a store toggle, not a payment method row.
Problems solved
- A test built all Supertest requests up front; Supertest closes the ephemeral server after the first request, so the rest failed with "Cannot read properties of null (reading 'address')". Requests are now built lazily.
- The Docker containers had stopped mid-session;
pnpm db:uprestored them and the in-memory test database was re-migrated by the test setup. - The unused
RECEIPT_MAX_BYTESenv variable was removed (the 5 MB limit is a constant).
Built
- Sign up and sign in pages, a session restored from the refresh cookie on load, and an owner API client that refreshes an expired access token and retries once.
- Route guards: signed-out owners go to
/login?redirect=…and come back; owners without a store go to onboarding; signed-in owners skip the auth pages. - Onboarding: store name, public URL suggested from the name with a live availability check, country (guessed from the browser, names in the interface language) and the WhatsApp number normalized to E.164 for that country.
- Every owner page is a lazy chunk, so shoppers never download the dashboard.
Key decisions
- Access token in memory only (Zustand, not persisted); a reload calls
/auth/refreshwith the httpOnly cookie. - Single-flight refresh: refresh tokens rotate and a replayed one revokes the family, so parallel 401s share one refresh request (covered by tests).
- Same-origin redirects only after sign in (
//evil.comand absolute URLs fall back to/dashboard). - Form schemas mirror the shared API rules with i18n keys as messages; the server stays the source of truth (
SLUG_TAKENis mapped back onto the URL field).
Problems solved
jsx-a11yrejectsautoFocus; the forms rely on the natural focus order instead.- A country field typed as the ISO code union needed separate form input and output types (
useForm<Input, unknown, Output>). - Docker Desktop was not running, so the API could not reach Postgres until it was started.
Built
- New default brand color (indigo
#4F46E5) on a white base with cool slate neutrals; Plus Jakarta Sans for headings. - Dark mode that follows the system, with a light / system / dark switch saved per device and applied before first paint.
- Redesigned landing (hero with an animated WhatsApp order preview, how-it-works cards) and a split auth layout with a brand panel on large screens.
- Data migration: stores still on the old default orange move to indigo; stores with a custom color keep it.
Key decisions
- Brand shades mix in OKLab, not OKLCH: white has hue 0 in OKLCH, so mixing indigo with it drifted toward pink.
- The theme lives on
<html data-theme>; without it the CSS followsprefers-color-scheme, so "system" reacts live. - Store pages still override
--brand, so every storefront keeps its own color in both themes.
Problems solved
- Prisma 7 does not run
generateaftermigrate dev, so the client kept the old column default until it was regenerated.
Built
- Dashboard shell: sidebar from
mdup, a bottom navigation within thumb reach on phones, a sticky header with the store name, a "view store" link and an account menu. - Dashboard home: a setup checklist computed from the owner endpoints (products, cash or a transfer method, hours, delivery zones when delivery is on) and a card to copy or share the store link on WhatsApp.
- Primitives: Switch, Tabs and ConfirmDialog on Radix, animated with Motion.
- Demo mode: a banner on the read-only account and
useCanWrite()for disabling save actions. - Orders, catalog and settings routes exist with a placeholder until their issues land.
Key decisions
- The shell lives in
features/dashboard(notcomponents/layout) because it reads the session;components/stays free of feature imports. - The checklist logic is a pure function (
setupChecklist) with its own tests; the hook only gathers data. - Owner queries share the
['owner', ...]key prefix, so signing out drops all of them. - Release milestones replaced the single MVP milestone: v0.5 Storefront, v0.6 Owner dashboard, v0.7 Demo & hardening, v1.0.0 Launch.
Problems solved
- Bash heredocs with long JSX failed to parse in this Windows shell; files are written directly instead.
- The shell test needed a
QueryClientProviderbecause the account menu uses the logout mutation.
Built
- Orders screen with tabs (active, completed, cancelled and expired, all), a payment filter and search by code, name or phone.
- Order rows: code, time since placed, customer, total in the order currency, status and payment badges, pickup or delivery, item count and minutes left for pending orders.
- "Load more" with the API cursor, a refresh every 30 seconds and on window focus, so new orders appear on their own.
Key decisions
- Filters live in the URL (
?tab=closed&payment=PAID&q=...), so refresh, the back button and shared links keep them; typing replaces the history entry instead of adding one per search. - Unpaid cash orders read "cash on delivery", not "unpaid": nothing is owed until delivery.
- A
--warning-inktoken gives amber text that keeps AA contrast in both themes.
Problems solved
- CI on the previous PR exposed a search bug: the digits of a code (
SW12CJ->12) matched customer phones. Phones are now searched only for phone-like queries (#42).
Built
- Order detail page (
/dashboard/orders/:id): code, status and date; snapshot lines with subtotal, delivery fee and total; customer with note, WhatsApp (pre-filled with the order code) and call buttons; delivery zone and address or pickup; payment method and status; timeline with who made each change. - Order rows link to the detail; "back" returns to the list with the same filters.
- A clear not-found state for unknown ids and other stores' orders (the API answers 404 for both).
Key decisions
- A page instead of a sheet: it can be shared and reloaded, and it works the same on phones and desktop.
- Notes written by the API itself (for example "Reactivated") are translated; notes typed by people are shown as written.
Built
- Actions card on the order detail: next statuses from
ORDER_TRANSITIONS(the first one highlighted), cancel behind a confirmation dialog, mark paid / unpaid, view receipt and reactivate expired orders. - Reactivation names the products that sold out when it fails with
OUT_OF_STOCK. - Every action updates the detail from the API response and refreshes the lists; the demo account sees them disabled.
- Toasts follow the light/dark choice.
Key decisions
- The receipt tab opens before the signed URL arrives, then navigates: opening it after an
awaitwould be blocked as a popup. - Signed receipt URLs are never cached: they expire in minutes.
- On phones the actions card comes first, so the usual flow (view receipt, mark paid, confirm) takes three taps from the top of the page.
Not verified live
- "View receipt" needs an uploaded receipt, which needs Cloudinary; it is not configured locally.
Built
- Catalog page with Products and Categories tabs (tab kept in the URL); the products tab arrives with #48.
- Categories: create, rename inline (Enter saves, Escape cancels), show/hide with a switch, delete behind a confirmation that says the products stay, reorder with up/down buttons.
Key decisions
- Reordering is optimistic: the list moves at once and rolls back if the API refuses.
- Up/down buttons instead of drag and drop: they work with a keyboard, a screen reader and one thumb.
- Icon-only buttons always carry an
aria-label(also fixed for the header's "view store" link on phones).
Built
- Products tab: photo or monogram, name, price in the store currency, category, stock for tracked products, and badges for hidden, sold out and low stock (5 or fewer).
- Search by name and category filter, both in the URL; "load more" with the cursor.
- Reorder with up/down buttons, optimistic with rollback.
SearchInputshared by orders and products.
Key decisions
- Reordering is offered only on the unfiltered list: the API assigns positions in the order it receives, so reordering a filtered subset would scramble the products that are not shown. The screen says why.
- Made-to-order products never show stock or a sold-out badge.
Process
- Milestones were standardized: issues only (no PRs), 4–6 issues per release, outcome descriptions, a due date on the active one. The owner dashboard became v0.7.0 Dashboard foundations, v0.8.0 Order management, v0.9.0 Catalog management and v0.10.0 Store settings.
Built
MoneyInput: currency sign fromIntl, decimal keyboard on phones, tidies the amount on blur ("2500" -> "2 500").parseMoneyInput: reads "2500", "2 500", "2.500,50" or "2,500.50"; the last separator is the decimal mark unless it closes a group of three digits; rejects more decimals than the currency has (JPY "10.5"); converts withtoMinor, never floats.- Product page for new and existing products: name, price, description, category, visible switch, "track stock" switch with the stock field only when on; delete behind a confirmation; creating a product opens its edit page (photos come next).
SwitchField(labelled switch with a hint) for settings screens too; rows in the products list link to the edit page; "Add product" in the header and the empty state.
Key decisions
- The form keeps the price as typed text and converts it once on submit, so what the owner typed is never rounded behind their back.
- Thousands groups must have exactly three digits ("1,5,0" is rejected instead of guessed).
Problems solved
no-irregular-whitespaceflagged literal no-break spaces in a regex;\salready matches them in JavaScript.- A session loss during testing came from a sign-out in the pane, not from refresh-token reuse: the API log showed the logout and a later refresh without a cookie.
Built
- Shared
IMAGE_MAX_BYTES(5 MB) andIMAGE_ACCEPTbetween API and web so the file picker checks format, size and the 8-photo limit before uploading, using the same error codes (UNSUPPORTED_FILE_TYPE,FILE_TOO_LARGE,TOO_MANY_IMAGES). uploadFile()XHR helper with upload progress (reused by tracking receipts) andownerUpload()with Bearer token and single refresh retry.ProductImagesandImageTileon the product edit page: multi-file sequential upload with live progress (current of total · %), cover badge on the first image, optimistic reorder with left/right arrows and rollback on failure, and delete behind aConfirmDialog.
Key decisions
- Multi-file uploads run one request after another rather than in parallel so each finished photo appears in the gallery right away and progress stays predictable on slow mobile connections.
- Reordering updates the cached product immediately and invalidates the product lists so the new cover shows up everywhere without a reload.
Built
/dashboard/settingspage (SettingsPage) with URL-syncedTabs(profile,delivery,payments,hours,localization, matchingsetupChecklistlinks); removed thedashboard-section-page.tsxplaceholder.ProfilePanel: logo and cover image upload/remove cards (StoreImageCard) with 5 MB and format checks, upload progress, and aConfirmDialogfor removal.- Store profile form (
profileFormSchema): name, description, E.164 WhatsApp phone validated against the store country, prices note, and brand color picker (BrandColorField) with hex input and a live product card + button preview usingbrandPropsandreadableForeground.
Key decisions
- The live brand color preview scopes
--brandand--brand-foregroundto the preview box withbrandProps(validColor, foreground)so light colors like yellow immediately flip the button label to black without re-theming the dashboard shell.
Built
OrderingOptionsCard(orderingFormSchema): pickup toggle + address, delivery toggle, cash on delivery/pickup toggle, accept orders while closed toggle, and pending order hold window (MIN_ORDER_EXPIRATION_MINUTES–MAX_ORDER_EXPIRATION_MINUTES); explains that at least one of pickup or delivery is required.DeliveryZonesCard(zoneFormSchema): create zones withMoneyInputin the store currency, inline edit of name and fee (ZoneEditor), show/hide switch, optimistic up/down reorder with rollback, and delete behind aConfirmDialog.
Key decisions
- Turning off both pickup and delivery blocks saving in the client with a clear
FormAlert(mirroring the API's rule onpickupEnabled) and clears the alert immediately when either switch is turned back on.
Built
PaymentMethodsPanel: cash toggle plus transfer payment methods (usePaymentMethodMutations) with add, inline edit, active switch, optimistic up/down reorder and delete behind aConfirmDialog.PaymentMethodFormView(paymentMethodFormSchema): preset selector powered bypresetsForCountry(store.country)(or custom) that pre-fills the label, localized identifier hint and default instructions, plus optionalhttps://payment link validation.
Key decisions
- Selecting a preset pre-fills the display name and customer instructions while leaving every field editable so owners can tailor the instructions or switch to a custom method at any time.
Built
HoursPanelandWeekdayRow: one row per weekday (Monday first) with open/closed switch, split shifts (up to 4 intervals per day), "Copy to Mon–Fri" and "Copy to all" helpers, "Clear all (always open)" button, and an "accept orders while closed" toggle.validateWeekSchedule(hours-form.ts): validates time ordering and highlights overlapping intervals on the offending shift (aria-invalid+ per-interval error message).
Key decisions
- An empty week displays a prominent notice explaining that the store is treated as always open, matching
isOpenAt([]) === truein@booth/shared.
Built
Combobox(components/ui/combobox.tsx): keyboard-accessible searchable combobox primitive for long option lists (currencies and IANA time zones).LocalizationCard(features/settings/localization/): country (Intl.DisplayNames), currency (Intl.supportedValuesOf('currency'), locked with an explanation whencanChangeCurrencyis false), time zone (Intl.supportedValuesOf('timeZone')), and store language (es/en).PrivacyCard(features/settings/privacy/): displays the effective privacy notice and version badge (vN), lets owners write a custom notice or reset to the country default (privacyNotice: null), and explains that saving increments the version recorded on future orders.
Key decisions
- When the combobox opens on focus, the search input clears its query while keeping the selected label as the placeholder, so owners can type immediately to filter 160+ currencies or 400+ time zones without backspacing first.
Built
seedDemoData(apps/api/src/seed/+apps/api/prisma/seed.ts): idempotent seed script wired topnpm --filter api db:seedandpnpm db:seed.- Two demo stores with read-only demo owners (
isDemo = true): Panadería La Esquina (panaderia-demo, Costa Rica,CRC, SINPE Móvil + cash, pickup + 3 delivery zones, Monday–Saturday hours, 15 products in 4 categories) and Bruma Coffee Roasters (cafe-demo, Colombia,COP, Nequi + Daviplata, delivery only, split shifts, 12 products in 4 categories, English locale). - Sample orders across every status (
PENDINGunpaid,PENDINGwithRECEIPT_SUBMITTED,CONFIRMED,PREPARING,READY,COMPLETED,CANCELLED,EXPIRED) with realistic status timelines and consistent reserved stock.
Key decisions
- Every entity is upserted by a stable key (owner email, store slug,
(storeId, name)for categories, deterministic ids for zones, payment methods, products and orders), and tracked product stock is computed asinitialStock - sum(active/completed order quantities)so re-running the seed never drifts stock or duplicates rows.
Built
- Hardened HTTP headers on the API (
app.setup.ts:frameguard: deny,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy,frame-ancestors 'none') and addedapps/web/vercel.jsonwithContent-Security-Policy(including SHA-256 hashes for both inline scripts inindex.html), HSTS,X-Frame-Options,X-Content-Type-Options,Referrer-Policy, andPermissions-Policy. - Added
apps/web/src/security-headers.test.tsto verify that every inline<script>inindex.htmlmatches its CSP hash invercel.json. - Expanded API security e2e tests (
security.e2e-spec.ts,rate-limit.e2e-spec.tswithTRUST_PROXY=2spoofed-header resistance,tenant-isolation.e2e-spec.tsstore branding isolation,owner-orders.e2e-spec.tsreceipt privacy on public tracking). - Enabled Jest coverage reporting and thresholds (
85%statements/lines/functions) andpnpm audit --prod --audit-level=high+pnpm test:covin.github/workflows/ci.yml.
Built
- Configured Vite
build.rollupOptions.output.manualChunksinapps/web/vite.config.tsto split vendor dependencies into long-term cacheable chunks:vendor-react(react,react-dom,scheduler),vendor-router(react-router),vendor-query(@tanstack/react-query), andvendor-i18n(i18next,react-i18next). - Reduced initial entry JS chunk (
index-*.js) from 361.00 kB (118.10 kB gzipped) to 161.60 kB (55.91 kB gzipped) — a 55% reduction in initial entry script size before compression.
Key decisions
- Splitting heavy vendor dependencies preserves immutable cache hits across deployments: storefront shoppers only download changed application code rather than re-downloading React, React Router, Query, and i18n runtimes on every release.
- Kept
libphonenumber-js(phone-*.js),zod, andmotion-featureslazily loaded or dynamic so the first paint on/s/:slugremains lightweight.
Built
- Created comprehensive
docs/DEPLOYMENT.mdcovering architecture, free-tier limits (Neon, Render, Vercel, Cloudinary), environment variables per service, step-by-step deployment guide, database migrations, rollbacks, and cold-start handling. - Configured production API rewrite in
apps/web/vercel.json(/api/:path*-> Render backend URL) keeping authentication refresh cookies first-party on the Vercel domain. - Created
.github/workflows/expire-orders.ymlscheduled workflow running every 10 minutes to executePOST /api/internal/jobs/expire-orderswithX-Job-Secret, automating order expiration and keeping the Render free instance active during working hours. - Added Render Continuous Deployment hook trigger in
.github/workflows/ci.ymlafter CI passes onmain. - Updated documentation links in
README.mdand checked off deployment setup items indocs/PLAN.md.
Key decisions
- The scheduled order expiration job checks if
JOB_SECRETis populated before calling the API, ensuring automated runs skip cleanly without spurious failures when secrets are not yet configured. - The Render Deploy Hook in
ci.ymltriggers only on pushes tomainwhenRENDER_DEPLOY_HOOK_URLis set, preserving decoupled build triggers while automating production releases.
Built
@booth/shared: added receipt extraction and verification schemas inpackages/shared/src/receipts/schema.ts(receiptExtractionSchema,receiptVerificationStatusSchema,receiptConfidenceSchema,receiptVerificationResultSchema), exported from@booth/shared.- Added unit tests in
packages/shared/src/receipts/receipts.test.tscovering valid payloads, nullable missing fields, minor unit amounts, and status enums. - Added optional
GEMINI_API_KEYtoapps/api/src/config/env.schema.tsandAppConfig, documented inapps/api/.env.example. - Implemented
ReceiptExtractorService(apps/api/src/receipts/receipt-extractor.service.ts) using Gemini 2.0 Flash REST endpoint with structured JSON output schema, graceful offline fallback when no API key is provided, network timeouts, and image URL fetching. - Added comprehensive unit tests in
apps/api/src/receipts/receipt-extractor.service.spec.tscovering unconfigured key handling, SINPE Móvil minor unit extraction, USD receipts, blurry/unreadable images, API error handling, and request timeouts. - Updated
apps/api/test/setup/global-setup.tsto handle offline Postgres connections gracefully during fast unit test execution.
Key decisions
- Direct REST call to Gemini 2.0 Flash (
https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent) withresponseMimeType: "application/json"using Node 24 nativefetch, eliminating additional heavy SDK dependencies. - Extracted amounts are normalized into integer minor units using
@booth/shared(toMinor) with ISO 4217 standard currency exponents (e.g. CRC exponent 2 céntimos). - Safe fallback: if
GEMINI_API_KEYis omitted or unreadable/blurry receipt is encountered, extractor returnsnullorconfidence: 'LOW', allowing downstream verification to mark the receipt asUNVERIFIEDfor owner review without failing the request or blocking the customer.
Built
- Prisma schema & migration (
20261018014500_add_receipt_verification): createdReceiptVerificationmodel withextractedAmountMinor,extractedCurrency,referenceNumber,recipientPhone,recipientName,senderName,transactionDate,bankName,status(MATCHED,AMOUNT_MISMATCH,RECIPIENT_MISMATCH,DUPLICATE_REFERENCE,UNVERIFIED),confidence,rejectionReason,verifiedAt, and partial unique index on(storeId, referenceNumber)forstatus = 'MATCHED'. ReceiptVerificationService(apps/api/src/receipts/receipt-verification.service.ts):- Validates
extractedAmountMinor === order.totalMinorand matching currencies. - Checks recipient phone number against store WhatsApp phone when detectable.
- Queries existing matched verifications within the same store to guard against recycled voucher fraud (
DUPLICATE_REFERENCE). - Verifies date recency (marks
UNVERIFIEDif transaction date is older than 48 hours). - Automatically transitions
order.paymentStatustoPAID, clearsexpiresAt, and creates anOrderStatusEventwith actorsystemupon matching.
- Validates
- Hooked
ReceiptVerificationServiceintoTrackingService.uploadReceipt, ensuring private storage and order status transitions are synchronized. - Added comprehensive unit tests in
receipt-verification.service.spec.tscovering valid auto-approval, amount mismatch, duplicate reference rejection, recipient phone mismatch, and old transaction dates.
Key decisions
- Partial unique index on PostgreSQL (
WHERE "status" = 'MATCHED' AND "referenceNumber" IS NOT NULL): ensures database-level uniqueness across approved receipts while still allowing duplicate attempts to be recorded in the database asDUPLICATE_REFERENCEfor owner review without causing unique constraint insert errors. - Graceful error isolation in
TrackingService.uploadReceipt: verification errors or unconfigured AI credentials never fail the customer's upload request, leaving the order inRECEIPT_SUBMITTEDfor standard manual review.
Built
@booth/shared: extendedownerOrderSummarySchemawithreceiptVerificationStatusandownerOrderSchemawithreceipt.verificationdetails (receiptVerificationResultSchema).apps/api:- Updated
OwnerOrdersServiceto queryreceipt.verificationfor order lists and order details. - Updated
owner-order.mapperto mapreceiptVerificationStatusto order summaries and full verification payload (extracted,confidence,mismatchReason,verifiedAt) to detailed orders. - Added unit tests in
owner-order.mapper.spec.tsfor mapped orders and receipt verification data.
- Updated
apps/web:- Created
ReceiptVerificationBadgecomponent displaying status indicators (MATCHED,AMOUNT_MISMATCH,DUPLICATE_REFERENCE,RECIPIENT_MISMATCH,UNVERIFIED) with dedicated tones and Lucide icons. - Created
ReceiptVerificationCardcomponent featuring status alert banners, data comparison table (detected amount vs order total, reference number, bank/app name, destination phone/name, sender, transaction date, AI confidence badge, raw extraction summary), view original receipt button, and one-click payment confirmation. - Integrated
ReceiptVerificationCardintoOrderDetailPagewhenever an order includes a receipt, placing AI feedback at the top of the action column. - Displayed
ReceiptVerificationBadgealongsidePaymentBadgeinOrderRowfor instant at-a-glance status in the dashboard orders list. - Added comprehensive Spanish and English translations in
es.jsonanden.jsonwith 100% key parity. - Added unit test suite in
receipt-verification-card.test.tsxand updatedorder-row.test.tsxcovering all verification outcomes, actions, and read-only demo account protection.
- Created
Key decisions
- Direct one-click confirm payment in
ReceiptVerificationCard: Allows store owners to inspect discrepancies (e.g. slight overpayment or tip) and immediately approve the payment without having to navigate to secondary action menus. - Connected component pattern with test injection:
ReceiptVerificationCardseamlessly connects touseOrderActionsin production while accepting optional injected actions for lightweight, fast unit testing without requiring global query providers. - Status badges in order list: Surfacing the AI verification status directly in
OrderRowallows merchants to identify orders needing attention (such asMonto diferenteorComprobante duplicado) immediately from the list view without opening each order.
Built
- Installed
@playwright/testin@booth/weband configured cross-browser test runner inapps/web/playwright.config.ts:- Desktop Chromium (1280×720), Mobile Chromium / Pixel 5 (360×800, touch).
- Desktop WebKit (1280×720), Mobile WebKit / iPhone 12 (360×800, touch).
- Retains traces and videos on failure, captures screenshots only on failure.
- Spawns both backend API and Vite dev web servers automatically with health-check readiness (
/healthon port 3000 and port 5173).
- Implemented 5 complete browser E2E test scenarios in
apps/web/e2e/:checkout-pickup.spec.ts: Customer storefront navigation, item selection, floating cart bar bump, slide-over sheet checkout, pickup fulfillment, cash payment, privacy consent checkbox, order placement, and verification of WhatsApp order tracking link.checkout-delivery-receipt.spec.ts: Storefront navigation, delivery fulfillment selection, zone fee calculation, exact address entry, SINPE transfer payment selection, privacy consent, order submission, transfer details verification, and payment receipt file upload.owner-order-flow.spec.ts: Store owner authentication (panaderia@demo.booth.app), navigation to orders dashboard, opening pending order#PAN101, and sequentially advancing through full order lifecycle (PENDING->CONFIRMED->PREPARING->READY->COMPLETED).owner-reactivate-expired.spec.ts: Owner login, navigating to closed/expired orders tab, inspecting expired order#PAN108with expired explanation banner, and reactivating order back to activePENDINGstate.demo-mode.spec.ts: Logging in with read-only demo credentials (cafe@demo.booth.app), asserting visibleDemoBanner, verifying that settings save button is disabled, and verifying that product edit save button in catalog is disabled.
- Updated
apps/apiseed runner (seed-types.ts,seed-runner.ts,panaderia-seed.ts,cafe-seed.ts):- Added optional
isDemoparameter onDemoStoreSeed.owner(defaults totrue). - Configured
panaderia-demoowner withisDemo: falseto allow full order management and reactivation in E2E tests, while keepingcafe-demoowner withisDemo: trueas the canonical read-only demo showcase.
- Added optional
- Integrated automated Playwright E2E pipeline into
.github/workflows/ci.yml:- Migrates and seeds PostgreSQL test container service with
DATABASE_URL_TEST. - Installs Chromium and WebKit system binaries with dependencies via
playwright install --with-deps chromium webkit. - Runs full E2E test suite in CI with failure report artifact upload (
actions/upload-artifact@v4).
- Migrates and seeds PostgreSQL test container service with
- Refined
apps/web/vite.config.tsto explicitly isolate unit tests (src/**/*.{test,spec}.{ts,tsx}) from E2E specs (e2e/**), preventing Vitest runner conflicts.
Key decisions
- Playwright network route interception for Cloudinary upload in
checkout-delivery-receipt.spec.ts: Intercepts**/public/orders/*/receiptto fetch the real placed order viapage.request.getand respond with verified receipt metadata, ensuring tests run reliably in CI without third-party external cloud storage dependencies. - Distinct demo vs active accounts: Having
cafe@demo.booth.appasisDemo: trueandpanaderia@demo.booth.appasisDemo: falseallows exercising both the restrictive read-only security guards and the full order state machine without artificial test bypasses.
Method
- Local production build served with Brotli and immutable caching (as on Vercel),
/apiproxied to the local API. Lighthouse 12, mobile emulation (4x CPU, slow 4G), median of 5 runs. Before =developat the same moment, same conditions.
| Page | Before | After | TBT after |
|---|---|---|---|
| Landing | 59 | 70 | 727 ms |
| Storefront | 61 | 61 | 877 ms |
| Sign in | 68 | 67 | 610 ms |
Accessibility 96–100 (the 96 comes from contrast measured while text fades in), Best practices 96–100, SEO 100, CLS 0.
Fixed
- Zod (86 KB) was back in the first load:
lib/domain.tsimportedRESERVED_SLUGSfrom the module that also builds the slug schema. The slug rules now live in a Zod-free module. - The vendor chunk rule matched loosely (
includes('react-dom/')caught@floating-ui/react-dom,'react/'caughtlucide-react), forcing Floating UI and every icon into the first load; then, matching the firstnode_modulesof pnpm paths, vendor chunks stopped splitting. It now matches the exact package folder. - Landing and storefront are separate chunks (each page no longer downloads the other); the page for the current URL starts downloading before the router mounts.
- English translations load on demand; Spanish (default) stays in the bundle.
Not fixed yet
- The remaining cost is React DOM, i18next and TanStack Query evaluation plus style/layout work on the storefront. Reaching 90+ on a simulated mid-range phone needs pre-rendered store HTML (see #26) and has to be measured on the deployed site.