diff --git a/docs/prd-product-tags.md b/docs/prd-product-tags.md new file mode 100644 index 00000000..b654ceae --- /dev/null +++ b/docs/prd-product-tags.md @@ -0,0 +1,736 @@ +# PRD: Product Tags — "Best Seller", "New", and Highlighted Menu Sections + +## Problem Statement + +The shop regularly has menu items it wants customers to notice first — a new Pancong topping, a +new seasonal drink, the coffee that sells the most. Today there is no way to say so anywhere in +the system: + +- **No tag concept exists.** A `Product` (`apps/api/domain/product_entity.go`) carries a single + `Category`, a `Status` (draft/published) and availability fields; a `Variant` + (`apps/api/domain/variant_entity.go`) carries price, materials, option values and availability. + Nothing can mark either as "new" or "best seller". +- **The order app menu is category-only.** `MenuListHandler.groupByCategory` + (`libs/ui/src/presentation/handlers/order/MenuListHandler.tsx`) turns the fetched products into + one group per category, and `MenuListScreen` renders those groups top to bottom. The customer + has no recommendation and cannot tell which items are new. +- **The product cards have nowhere to say it.** `MenuProductCard` + (`libs/ui/src/presentation/views/components/menu/MenuProductCard.tsx`) shows image, name, a + sold-out badge, search-match chips, description and starting price. The POS item picker's + `ProductListItem` (`libs/ui/src/presentation/views/components/products/ProductListItem.tsx`) + shows category, sale type, status and availability badges. Staff can't see it either. +- **A variant has no image.** `variants` (`000001_initial_schema.up.sql`) has no `image_url`; + only `products.image_url` exists. A "New: Pancong Ice Cream" card can only show the generic + Pancong photo. + +### Root cause + +"New" and "best seller" are facts about **what the customer can order**, and in this system what +the customer orders is a **variant** — the cart and the transaction both hold `variantId`, and a +product must have at least one variant to be sold at all. But the way people *talk* about these +facts is at whatever level is natural: "Coffee Latte is a best seller" (product), "the Ice Cream +Pancong is new" (one variant), "Salted Caramel Macchiato is new" (a product whose single +`Original` variant only exists because the system requires one). + +Any design has to store the fact at one level and *display* it at the level that is true. The +three cases from the brief are the test: + +| # | Item | Variants | What must be true on screen | +| --- | --- | --- | --- | +| 1 | **Pancong** | Choco, Matcha, Vanilla, **Ice Cream** | Pancong is **not** new. The Ice Cream variant **is** new, and the "New" section shows a *Pancong · Ice Cream* card with the Ice Cream image. | +| 2 | **Salted Caramel Macchiato** | Original (only one) | The **product** is new. The "New" section shows the product card, not a variant card. The admin never has to think about "Original". | +| 3 | **Coffee Latte** | Hot, Iced | The **product** is a best seller (both temperatures). The "Best Seller" section shows one Coffee Latte card, not two. | + +--- + +## How the Industry Handles This + +Observed patterns in food-ordering products (from product use, not independently re-verified for +this PRD — treat as background, not as a spec): + +1. **Delivery marketplaces put a curated/popular strip above the category menu.** GoFood, + GrabFood and ShopeeFood merchant pages open with a "Terlaris / Best seller / Popular" block + before the categories. Items in that block are *duplicated*, not moved — they still appear + under their own category. +2. **Badges are short, coloured pills on the item card**, usually one or two ("Baru", "Terlaris", + "Pedas"). More than two becomes noise. +3. **Merchant-side, tags are a small managed list** (Square Online item labels, Shopify product + tags/collections): the merchant creates the label once and applies it to many items. +4. **Marketplaces badge the orderable unit.** When one flavour of a dish is promoted, the + promoted card is that flavour, with its own photo and price. + +Pattern 1 and 4 together are exactly case 1: the highlighted card is the variant, the regular +menu still shows the product. + +--- + +## Alternatives Considered + +### Option A — Tags on products only ❌ + +`product_tags(product_id, tag_id)`. + +- ✅ Simplest possible model and UI; covers cases 2 and 3 directly. +- ❌ **Cannot express case 1.** Tagging Pancong "New" would claim Choco/Matcha/Vanilla are new — + the exact thing the brief says must not happen. Workaround (split Ice Cream into its own + product) breaks the option picker, availability counters and reporting. + +### Option B — Tags on products *and* variants (two join tables) ❌ + +`product_tags` means "the whole product, including future variants"; `variant_tags` means "only +this variant". + +- ✅ Stores the admin's words literally ("the product is new"). +- ❌ **Two sources of truth for one fact.** Coffee Latte could be tagged at product level *and* + Hot at variant level; every reader needs a precedence rule, and the admin UI needs to explain + the difference between "tag the product" and "tag all its variants" — which look identical today + and diverge silently later. +- ❌ **Inheritance is wrong half the time.** A product-level "New" inherited by a variant added + next month is right; a product-level "Best Seller" inherited by a brand-new Oat Milk variant is + a false claim shown to customers. + +### Option C — Tags on option values (e.g. the "Ice Cream" topping value) ❌ + +- ✅ Matches how the customer picks. +- ❌ Breaks for multi-option products — "Ice Cream" × "Large" is one variant, two option values; + which one carries "New"? +- ❌ Does nothing for cases 2 and 3 without a product-level table as well (→ Option B's problems). + +### Option D — Tags on variants only; product-level is *derived* ✅ **Recommended** + +One join table, `variant_tags(variant_id, tag_id)`. A product "has" a tag at product scope when +**every live variant** carries it; otherwise it has the tag at variant scope, naming which +variants. The derivation is a pure function on the server, exposed on every product read. + +- ✅ **One source of truth**, stored at the level the system actually sells. +- ✅ All three cases fall out with **no special-casing**: Ice Cream only → variant scope; one of + one → product scope; two of two → product scope (see the table below). +- ✅ **Never shows a claim nobody made.** A variant added later carries no tag until someone + gives it one (D4) — and the create-variant form pre-fills the product's current product-scope + tags so the admin makes that call consciously, in one glance. +- ✅ The admin UI can still *feel* product-level: a tri-state product checkbox ticks all variants, + and single-variant products never show their variant at all (D14). +- ⚠ Trade-off: "product scope" is a computed state, so adding an untagged variant to Coffee Latte + demotes it from one product card to per-variant cards in the Best Seller section. Accepted — + that is the truthful display, and D4's pre-fill makes it the admin's explicit choice. + +--- + +## Proposed Solution + +### High-level system design + +```mermaid +flowchart LR + subgraph POS["POS (pos-web / pos-mobile)"] + TM["Tag management
list · create · edit · delete"] + TA["Tag assignment
per tag: product ▸ variant checklist"] + VF["Variant form
+ image URL, + tags"] + PC1["Item picker / product list
tag badges"] + end + + subgraph API["apps/api (Go)"] + TagUC["TagUsecase
CRUD + SetTagVariants"] + Resolve["ResolveProductTags(variants)
pure domain fn"] + ProdUC["ProductUsecase / VariantUsecase
(existing reads)"] + end + + subgraph DB["MySQL"] + tags[("tags")] + vt[("variant_tags")] + variants[("variants
+ image_url")] + end + + subgraph ORDER["Order app (order-web)"] + HL["Highlight sections
(one per highlighted tag)"] + CAT["Category sections
(unchanged grouping)"] + PC2["Menu cards
tag badges"] + end + + TM --> TagUC + TA --> TagUC + VF --> ProdUC + TagUC --> tags + TagUC --> vt + ProdUC --> variants + ProdUC --> vt + ProdUC --> Resolve + Resolve -- "Product.tags[] (scope + variantIds)
Variant.tags[]" --> PC1 + Resolve -- "GET /public/products, /public/variants" --> HL + Resolve --> PC2 + HL --> PC2 + CAT --> PC2 +``` + +**In one paragraph:** admins manage a small list of **tags** (name, colour, "show as a section on +the order app", sort order) and attach them to **variants**. Every product read runs a pure +`ResolveProductTags` over the product's variants and returns `Product.tags[]`, each entry saying +whether the tag applies to the **whole product** or only to **specific variants**. Both apps +render badges from that one field. The order app additionally builds one **highlight section per +highlighted tag**, above the category sections, from data it already fetches — a product-scope +entry becomes a product card, a variant-scope entry becomes a card for that variant, with its own +image and price, which opens the detail sheet with that variant's options pre-selected. + +### Data model + +```mermaid +erDiagram + products ||--o{ variants : has + variants ||--o{ variant_tags : "tagged with" + tags ||--o{ variant_tags : "applied to" + + tags { + BIGINT id PK + VARCHAR name "unique, case-insensitive" + VARCHAR color "palette enum" + TINYINT is_highlighted "show section on order app" + INT sort_order "section + badge order" + DATETIME created_at + } + variant_tags { + BIGINT variant_id PK,FK + BIGINT tag_id PK,FK "ON DELETE CASCADE" + DATETIME created_at + } + variants { + VARCHAR image_url "NEW, nullable; falls back to product image" + } +``` + +| Table | Change | +| --- | --- | +| `tags` | **New.** `id`, `name VARCHAR(100) NOT NULL` + `UNIQUE (name)` (the default utf8mb4 collation makes it case-insensitive), `color VARCHAR(20) NOT NULL DEFAULT 'gray'`, `is_highlighted TINYINT(1) NOT NULL DEFAULT 0`, `sort_order INT NOT NULL DEFAULT 0`, `created_at`. | +| `variant_tags` | **New.** `PRIMARY KEY (variant_id, tag_id)`, `KEY (tag_id)`, FK to `variants`, FK to `tags` `ON DELETE CASCADE`, `created_at`. | +| `variants` | **New column** `image_url VARCHAR(255) NULL`. | + +### The three cases, expressed + +| Item | `variant_tags` rows | Resolved `Product.tags` | Order app "highlight" card | +| --- | --- | --- | --- | +| **Pancong** | (Ice Cream, New) | `[{ tag: New, scope: variant, variantIds: [IceCream] }]` | **Pancong · Ice Cream** — Ice Cream image, Ice Cream price; opens detail with *Ice Cream* pre-selected | +| **Salted Caramel Macchiato** | (Original, New) | `[{ tag: New, scope: product, variantIds: [Original] }]` | **Salted Caramel Macchiato** — product image, starting price | +| **Coffee Latte** | (Hot, Best Seller), (Iced, Best Seller) | `[{ tag: Best Seller, scope: product, variantIds: [Hot, Iced] }]` | **Coffee Latte** — one card, product image, starting price | + +And in the regular category list: + +| Item | Card badges | +| --- | --- | +| **Pancong** | `New · Ice Cream` (variant-scope pill naming the variant) — Pancong itself is *not* badged "New" | +| **Salted Caramel Macchiato** | `New` | +| **Coffee Latte** | `Best Seller` | + +### Resolution rule + +``` +ResolveProductTags(variants []Variant) []ProductTag + live := variants where DeletedAt == nil + for each tag T appearing on any live variant: + tagged := live variants carrying T + scope := "product" if len(tagged) == len(live) else "variant" + emit { Tag: T, Scope: scope, VariantIds: ids(tagged), + TaggedAt: max(variant_tags.created_at over tagged) } + sort by T.SortOrder, then T.Name +``` + +Coverage counts **live** variants, not **sellable** ones (D2): Coffee Latte stays a product-scope +best seller while Iced is sold out. + +--- + +## Feature Requirements + +### FR-1 — Tag management (POS) + +A **Tags** entry in the POS sidebar, beside Categories, opens a list screen with create, edit and +delete, shaped exactly like the existing Category screens (`CategoryListScreen`, +`CategoryCreateScreen`, `CategoryUpdateScreen`). Fields: + +| Field | Input | Rule | +| --- | --- | --- | +| Name | text | required, 1–100 chars, unique ignoring case | +| Colour | swatch picker | one of `red, orange, yellow, green, blue, purple, pink, gray` (D5) | +| Show as section on order app | switch | `is_highlighted` | +| Sort order | number | lower first; orders both sections and badges | + +Delete asks for confirmation and states how many variants carry the tag; it removes the tag and +its assignments (D9). + +API: `GET /tags`, `POST /tags`, `GET /tags/{tagId}`, `PUT /tags/{tagId}`, `DELETE /tags/{tagId}`, +all under `CheckAuth`. `Tag` schema: `id, name, color, isHighlighted, sortOrder, createdAt`. + +### FR-2 — Tag assignment (POS) + +From a tag's row, **Assign products** opens one screen per tag: every product, grouped by +category, searchable, with a checkbox per product and — for products with ≥ 2 variants — an +expandable checklist of its variants (D14). + +- The product checkbox is **tri-state**: checked (all live variants tagged), indeterminate (some), + unchecked (none). Ticking it ticks every variant; unticking clears them. +- A **single-variant product shows only the product checkbox** — the admin tags "Salted Caramel + Macchiato", never "Original". +- One **Save** sends the full set: `PUT /tags/{tagId}/variants` with `{ variantIds: [...] }`, + replacing that tag's assignments (D10). + +Data for the screen comes from the existing `GET /variants` (no `limit` = all rows, +`GetLimit` in `apps/api/presentation/restapi/base_transformers.go`), each variant now carrying +`tags` and its `product`; the screen groups by product client-side. + +### FR-3 — Variant form: image and tags (POS) + +`VariantFormView` gains: + +- **Image URL** (optional) — same text field as the product form's `imageUrl` + (`ProductFormView.tsx:126`). Empty means "use the product image". +- **Tags** — multi-select of all tags. On **create**, pre-filled with the tags that are currently + **product-scope** on the parent product (D4), so adding "Oat Milk" to a best-selling Coffee Latte + asks the admin, visibly, whether it is a best seller too. + +`VariantRequest` gains optional `imageUrl` and `tagIds`; **omitted `tagIds` leaves assignments +alone** (D10), so older clients and other edit paths never wipe tags. + +### FR-4 — Tags on every product and variant read (API) + +| Schema | New field | +| --- | --- | +| `Variant` | `tags: VariantTag[]` where `VariantTag = { tag: Tag, taggedAt: date-time }`, `imageUrl?: string` | +| `Product` | `tags: ProductTag[]` where `ProductTag = { tag: Tag, scope: 'product' \| 'variant', variantIds: int64[], taggedAt: date-time }` | + +`taggedAt` is `variant_tags.created_at` — for a `ProductTag`, the most recent one among its tagged +variants. It orders highlight entries newest first (D17). + +Populated on the authenticated and the public endpoints alike, computed by +`ResolveProductTags` (D3) at the same point `ProductUsecase.resolveAvailability` +(`apps/api/domain/product_usecase.go:16`) already loads the product's variants — the variant query +preloads `Tags`, adding no extra round trip per product. + +### FR-5 — Badges on product cards (POS and order app) + +One shared `TagBadge` component (coloured pill, colour from the tag's palette value, readable in +light and dark). Rules, identical in both apps: + +- Product-scope tags → pill with the tag name (`Best Seller`). +- Variant-scope tags → pill with tag and variant name (`New · Ice Cream`); if more than one + variant, `New · 2 varian`. +- At most **2** pills, in tag sort order, then `+N` (D12). +- Placement: POS `ProductListItem` subtitle (next to the sold-out/remaining badges — used by both + the product list and the transaction item picker); order app `MenuProductCard` under the name. +- In the order app detail sheet, once a variant resolves, its own tags show beside the price. + +### FR-6 — Highlight sections on the order app menu + +Above the category groups, one section per tag with `isHighlighted = true` that has at least one +entry, ordered by `sortOrder`: + +- **Header**: tag name (e.g. "Best Seller", "New"). +- **Body**: a horizontal carousel of compact cards (image on top, name, optional variant name, + price), so two sections don't push the regular menu off the first screen (D7). +- **Entries**, built from the `products` and `variants` the menu already loads + (`ApiMenuRepository.fetchMenu`), no new endpoint (D15): + - product-scope → one **product card**: product image, product name, starting price; + - variant-scope → one card **per tagged variant**: variant image (fallback: product image), + "Pancong" + "Ice Cream", that variant's price. +- **Entries are ordered most recently tagged first** (`taggedAt` descending, then menu order), + with **no per-section cap** — the carousel scrolls (D17, D18). +- **Only sellable entries** appear (D8); an empty section is not rendered. +- **Tapping** a product card opens the detail sheet as today; tapping a variant card opens it + with that variant's option values pre-selected — reusing `preselectedOptionValueIds` on + `menuItemDetail`'s `SELECT_PRODUCT` action (D6). +- Sections render **only when the search box is empty and no category or tag chip is + selected** (D7). Items still appear in their normal category below — sections duplicate, never + move. + +### FR-7 — Highlighted tags as chips on the order app menu + +`CategoryChipList` gains one chip per highlighted tag that has entries, placed after **Semua** and +before the category chips, in tag `sortOrder`, rendered in the tag's colour so they read as +different from categories (D16). + +- Tapping a tag chip **filters** the menu to that tag's entries, exactly the way a category chip + filters to its category today (`selectedCategoryId` in `MenuListUsecase`); the entries render + as a vertical list of full-width cards, in the same order as the section (D17). +- A tag chip and a category chip are **mutually exclusive**: selecting one clears the other. + **Semua** clears both and brings the highlight sections back. +- A tag chip only exists while its section would have at least one entry, so it never leads to an + empty list. + +--- + +## Design decisions + +**D1 — Tags attach to variants; product scope is derived.** A single `variant_tags` table; a +tag is product-scope for a product when every live variant carries it. *Alternative rejected:* +product + variant tag tables (Option B) — two sources of truth and an inheritance rule that is +wrong for "Best Seller". + +**D2 — Coverage is computed over live (non-deleted) variants, not sellable ones.** Availability +changes by the hour; a sold-out Iced must not flip Coffee Latte from one product card to a lone +"Hot" variant card. Sold-out is shown by the existing availability badges instead. + +**D3 — Resolution runs server-side in a pure domain function.** `ResolveProductTags` lives in +`apps/api/domain/tag_entity.go` next to the model, like `ResolveProductAvailability`, is +table-tested there, and is exposed on every product read. POS and the order app both get the +same answer without re-implementing the rule or fetching variants just to draw a badge. +*Alternative rejected:* resolve in `libs/ui` — the POS product list doesn't load variants, and two +implementations would drift. + +**D4 — New variants inherit nothing; the create form pre-fills instead.** Storage never infers a +claim. The create-variant form pre-selects the parent's current product-scope tags so the admin +confirms or clears them in the same form. This keeps "New" right (the admin leaves it ticked) and +"Best Seller" right (the admin unticks it). + +**D5 — Colour is a fixed palette, not free hex.** Eight named colours mapped to Tamagui `$5` +background / `$11` text tokens — the same pairing `SoldOutBadge` uses — so every tag is +legible in light and dark themes and nobody ships yellow-on-white. + +**D6 — A variant highlight card opens the product, pre-selected.** `MenuListUsecase`'s +`SELECT_ITEM` gains an optional `variantId`; `MenuListHandler` translates it into the variant's +option value ids and passes them as `preselectedOptionValueIds` to `SELECT_PRODUCT`, which already +exists for search matches. The customer lands on "Pancong, Ice Cream selected" and can still switch +topping. The URL keeps only `selectedProductId`, so a shared link opens the product without +pre-selection — acceptable. + +**D7 — Sections sit above categories, only in the default view, as carousels.** Search results and +a category filter are the customer asking for something specific; recommendations would get in the +way. Horizontal carousels keep the first screen from being all highlights. + +**D8 — Non-sellable entries are hidden from highlight sections.** Recommending something the +customer cannot order is worse than not recommending. They keep their badge in the category list, +where they already render disabled. + +**D9 — Tags are hard-deleted, assignments cascade.** Unlike categories (soft-deleted because +products point at them historically), nothing historical references a tag — transactions store +variants, not tags (D11). `ON DELETE CASCADE` on `variant_tags.tag_id` keeps deletion one +statement. + +**D10 — Two write paths, both explicit about scope.** `PUT /tags/{tagId}/variants` replaces the +full set for one tag (what the assignment screen edits). `VariantRequest.tagIds` replaces the set +for one variant, and **omitted means unchanged** — the same rule as availability's partial update +(`docs/prd-product-availability.md` D10), so no existing caller can wipe tags by not knowing about +them. + +**D11 — Tags are merchandising only.** Not snapshotted onto cart items or transactions, not on +receipts, KDS tickets or statistics. Removing "New" next month rewrites nothing. + +**D12 — At most two badges per card, then `+N`.** Beyond two, badges crowd out name and price on a +phone-width card. + +**D13 — Single-variant products need no special code.** One of one tagged is 100 % coverage → +product scope. Case 2 is a consequence of D1, not a branch. + +**D14 — The assignment screen hides the variant layer when it adds nothing.** Single-variant +products show only a product checkbox; multi-variant products show a tri-state product checkbox +with an expandable variant list. The admin works in product terms until a variant genuinely +differs. + +**D15 — No new public endpoint.** Tags ride on the existing `/public/products` and +`/public/variants` responses (with `isHighlighted` and `sortOrder` on the embedded `Tag`), and the +order app already holds both lists in `MenuListUsecase`'s context — highlight sections are a pure +function of state the handler already has. + +**D16 — Highlighted tags are also chips, and chips filter.** One chip per non-empty highlighted +tag, between **Semua** and the categories, in the tag's colour. A tag chip filters like a category +chip already does (`selectedCategoryId` filters `visibleGroups` in `MenuListHandler`; nothing on +the menu scrolls to an anchor today) — `MenuListUsecase` gains `selectedTagId`, mutually exclusive +with `selectedCategoryId`. *Alternative rejected:* scroll-to-section — it needs per-section layout +refs in a `ScrollView` the screen does not track today, and behaves differently from the category +chips right next to it. + +**D17 — Highlight entries are ordered most recently tagged first.** `variant_tags.created_at` is +exposed as `taggedAt` on `VariantTag` and `ProductTag` (latest across the product's tagged +variants); `buildTagHighlights` sorts by it descending, then by menu order. This is only true if +`created_at` means *when this variant got this tag*, so **both write paths diff instead of +delete-and-reinsert**: `PUT /tags/{tagId}/variants` and `VariantRequest.tagIds` insert only the +new pairs and delete only the removed ones, leaving untouched rows' `created_at` alone. Re-saving +the assignment screen must never make every item "newest". Untagging and re-tagging a variant does +restamp it — that is a deliberate re-promotion. + +**D18 — No per-section cap.** The horizontal carousel (D7) already bounds the vertical space a +section takes, however many entries it has; the tag chip (D16) gives the full list. A cap would +hide tagged items with no way to reach them from the section. + +--- + +## Settled in review + +Answers to the PRD's original open questions, recorded here rather than silently edited in: + +| # | Question | Answer | Recorded as | +| --- | --- | --- | --- | +| 1 | Should highlighted tags also appear as chips in `CategoryChipList`? | **Yes.** Implemented as a filter, matching how category chips behave. | FR-7, D16, Phase 13 | +| 2 | Menu order, or most recently tagged first? | **Most recently tagged first.** | FR-4 `taggedAt`, FR-6, D17, Phases 3, 4, 9 | +| 3 | Cap per section, or is the carousel enough? | **Carousel is enough** — no cap. | FR-6, D18 | + +--- + +## Phased plan + +Each phase is one PR, leaves `main` green and the product shippable on its own, and names its own +acceptance check. + +| # | Phase | Layer | Depends on | +| --- | --- | --- | --- | +| 1 | Tag CRUD API (`tags` table) | API | — | +| 2 | Variant image, end to end | API + POS | — | +| 3 | Variant ↔ tag assignment API (`variant_tags`) | API | 1 | +| 4 | Product tag resolution on reads | API | 3 | +| 5 | Frontend tag slice: entity, repository, CRUD use cases | libs/ui | 1 | +| 6 | POS Tag management screens + sidebar entry | POS | 5 | +| 7 | POS Tag assignment screen | POS | 3, 6 | +| 8 | POS variant form: tag multi-select with pre-fill | POS | 3, 5 | +| 9 | Frontend: tags on Product/Variant, `TagBadge`, `buildTagHighlights` | libs/ui | 4, 5 | +| 10 | POS product cards show tag badges | POS | 9 | +| 11 | Order app menu cards show tag badges | order | 9 | +| 12 | Order app highlight sections | order | 2, 9 | +| 13 | Order app highlighted-tag chips | order | 12 | +| 14 | Docs site page + e2e coverage | docs, e2e | 7, 10, 11, 13 | + +### What can run in parallel + +```mermaid +flowchart LR + P1["1 · Tag CRUD API"] --> P3["3 · Assignment API"] + P1 --> P5["5 · FE tag slice"] + P3 --> P4["4 · Product tag resolution"] + P3 --> P7 + P3 --> P8["8 · Variant form tags"] + P5 --> P6["6 · POS Tag screens"] + P5 --> P8 + P5 --> P9 + P6 --> P7["7 · POS assignment screen"] + P4 --> P9["9 · FE tags + TagBadge + highlights util"] + P9 --> P10["10 · POS badges"] + P9 --> P11["11 · Order badges"] + P9 --> P12["12 · Order highlight sections"] + P2["2 · Variant image"] --> P12 + P12 --> P13["13 · Order tag chips"] + P7 --> P14["14 · Docs + e2e"] + P10 --> P14 + P11 --> P14 + P13 --> P14 +``` + +| Wave | Phases that can be open at the same time | +| --- | --- | +| 1 | **1**, **2** | +| 2 | **3**, **5** | +| 3 | **4**, **6**, **8** | +| 4 | **7**, **9** | +| 5 | **10**, **11**, **12** | +| 6 | **13** (10 and 11 may still be open) | +| 7 | **14** | + +Phase 2 is fully independent and can land any time before 12. Admins can create tags after 6 and +tag products after 7 (or 8) — **before any customer sees anything**. Nothing customer-visible +changes until 11–13, so the shop can pre-load "New" and "Best Seller" and reveal them in one +deploy. + +> Every phase that touches `libs/api-contract/src/api.yaml` regenerates both clients +> (`npx nx run api-contract:generate:go`, `npx nx run api-contract:generate:ts`) and may need the +> new symbol added to `libs/ui/src/__mocks__/api-contract.ts`, which Jest substitutes wholesale +> for the generated package. + +--- + +### Phase 1 — Tag CRUD API + +Migration `000045_create_tags` (next free number; `000044_add_payment_verification` is the latest) +creating `tags` as in the data model, with a `down` that drops it. `Tag` entity and `TagColor` +string enum in `apps/api/domain/tag_entity.go`; `TagRepository` (`//go:generate mockgen`), +`TagUsecase` returning `(T, *domain.Error)` with a `BadRequest` on a duplicate name and an +unknown colour; MySQL repo/entity/transformer in `apps/api/data/mysql/`; handler, transformer and +routes under `CheckAuth` in `apps/api/presentation/restapi/`. Contract: `/tags`, `/tags/{tagId}`, +`Tag`, `TagRequest`, list/find/create/update responses. + +**Acceptance:** `tag_usecase_test.go` covers create, duplicate name differing only in case +(rejected), unknown colour (rejected), update, delete; `npx nx run api:test` green; +`MIGRATIONS_DIR=data/mysql/migrations make migrate-up && make migrate-down` clean both ways. + +### Phase 2 — Variant image, end to end + +Migration `000046_add_variant_image_url` (renumber if phase 3 lands first) adding +`variants.image_url VARCHAR(255) NULL`. `ImageUrl *string` on the Go `Variant` and its MySQL +entity/transformer; `imageUrl` on `Variant` and `VariantRequest` in `api.yaml`; `imageUrl` on +`libs/ui/src/domain/entities/Variant.ts`, `variant.transformer.ts` and the variant form schema; +an optional **Image URL** field in `VariantFormView`. No reader uses it yet. + +**Acceptance:** a variant round-trips through `PUT /variants/{id}` with and without `imageUrl`; +`VariantFormView` story shows the field; `npx nx run api:test` and `npx nx run ui:test` green. + +### Phase 3 — Variant ↔ tag assignment API + +Migration creating `variant_tags` as in the data model. `Tags []VariantTag` (tag + `TaggedAt`) on +the Go `Variant`, preloaded by every variant read in `variant_repo.go`. +`TagUsecase.SetTagVariants(ctx, tagId, variantIds)` replacing the set in one DB transaction by +**diffing** — insert added pairs, delete removed pairs, leave the rest (D17) — rejecting unknown or +deleted variant ids; `PUT /tags/{tagId}/variants`. `VariantRequest.tagIds` optional — +`VariantUsecase.CreateVariant` / `UpdateVariantById` apply the same diff only when it is present +(D10). `Tag.variantCount` on the tag list response for the delete confirmation. + +**Acceptance:** `tag_usecase_test.go` covers replace, empty set, unknown variant id (rejected); +`variant_usecase_test.go` covers update with `tagIds` omitted (tags untouched), empty (cleared), +and a list (replaced); a repo test shows re-saving an unchanged set keeps every row's +`created_at`; deleting a tag removes its `variant_tags` rows; `npx nx run api:test` green. + +### Phase 4 — Product tag resolution on reads + +`ProductTag` + `ResolveProductTags(variants []Variant) []ProductTag` in `tag_entity.go`, pure, +with `tag_entity_test.go` table-driving the three cases (Pancong, Salted Caramel, Coffee Latte), +a deleted variant ignored for coverage, a sold-out variant still counted (D2), `taggedAt` taking +the latest of the tagged variants (D17), and sort order. +Called from `ProductUsecase.resolveAvailability` (rename to `resolveDerivedFields`) where the +variants are already loaded; `tags: ProductTag[]` on the `Product` schema for authenticated and +public endpoints. + +**Acceptance:** the three-case table is a test; `GET /public/products` returns `tags: []` for +every untagged product and the expected scope for a seeded tagged one; `npx nx run api:test` green. + +### Phase 5 — Frontend tag slice + +No UI. `Tag` entity and `tagFormSchema` in `libs/ui/src/domain/entities/Tag.ts`; +`TagRepository` in `domain/repositories/`, implemented in `data/api/tag.ts` (+ transformer) and +`data/mock/tag.ts`; `TagListUsecase`, `TagCreateUsecase`, `TagUpdateUsecase`, `TagDeleteUsecase` +as `extends Usecase` state machines mirroring the `category*` use cases, +each with a `.test.ts` using `UsecaseTester` + `flushPromises` and +`MockTagRepository.setShouldFail(true)` for the error branch. Barrel exports. + +**Acceptance:** `npx nx run ui:test` green; use cases importable from `@gatherloop-pos/ui/pos`. + +### Phase 6 — POS Tag management screens + +`TagListScreen`, `TagCreateScreen`, `TagUpdateScreen` + `TagFormView` (owns `useForm` and the zod +resolver, `docs/forms.md`), `TagListItem` (shows colour pill, highlighted flag, variant count), +`TagDeleteAlert`; `TagListHandler` / `TagCreateHandler` / `TagUpdateHandler`; composition roots in +`libs/ui/src/app/pos/`; pages under `apps/pos-web/src/pages/tags/`; a **Tags** sidebar entry next +to Categories. Stories and handler tests for each screen. + +**Acceptance:** handler tests with real use cases over `MockTagRepository` cover create → list, +edit, delete-with-confirm; Storybook renders loaded/empty/error; `npx nx affected -t test lint` +green. + +### Phase 7 — POS Tag assignment screen + +`TagAssignmentUsecase` (load all variants via `VariantRepository`, `TOGGLE_VARIANT`, +`TOGGLE_PRODUCT` with tri-state semantics, `SEARCH`, `SAVE` → `TagRepository.setTagVariants`), +`TagAssignmentScreen` grouped by category → product → variants, single-variant products collapsed +to one checkbox (D14); `TagAssignmentHandler`; page `apps/pos-web/src/pages/tags/[tagId]/assign`; +"Assign products" action on `TagListItem`. + +**Acceptance:** use case test covers ticking a product (all variants), unticking one variant +(indeterminate), a single-variant product, and save sending exactly the ticked ids; handler test +asserts the checkbox states by accessible role; story for the Pancong/Salted Caramel/Coffee Latte +fixture. + +### Phase 8 — POS variant form: tag multi-select + +`tagIds` in the variant form schema and a multi-select in `VariantFormView`; `VariantCreate` / +`VariantUpdate` handlers load tags via `TagListUsecase`; on create, pre-fill with the parent +product's product-scope tags (D4) — the product's `tags` are already on the product the create +screen fetches once phase 4 is in; before that, pre-fill is empty and the phase still ships. + +**Acceptance:** handler test: creating a variant under a product with a product-scope "Best +Seller" shows it pre-ticked; unticking and saving sends `tagIds: []`; updating without touching +the field sends the existing ids. + +### Phase 9 — Frontend: tags on Product/Variant, `TagBadge`, highlight builder + +`tags: ProductTag[]` on `Product`, `tags: VariantTag[]` on `Variant`, and their transformers. +`TagBadge` component in `presentation/views/components/tags/` (palette → token map, D5) with +stories for every colour in both themes. `buildTagHighlights(products, variants)` in +`libs/ui/src/utils/` → `{ tag, entries: ({ kind: 'product', product } | { kind: 'variant', +product, variant })[] }[]`, filtering to highlighted tags and sellable entries (D8); sections ordered by tag `sortOrder`, +entries within a section by `taggedAt` descending, then menu order (D17). No screen changes. + +**Acceptance:** `buildTagHighlights.test.ts` covers the three cases, a non-highlighted tag +(no section), a sold-out variant entry (dropped), an empty section (dropped), and a newer tagged +variant sorting ahead of an older tagged product; `npx nx run ui:test` green. + +### Phase 10 — POS product cards show tag badges + +`ProductListItem` gains an optional `tags: ProductTag[]` prop rendered with `TagBadge` per FR-5 +(max 2 + `+N`, variant-scope pill naming the variant). Passed from `ProductList` and +`TransactionItemSelect`. + +**Acceptance:** `ProductListItem` stories for product-scope, variant-scope and overflow; the +transaction item picker story shows badges; `npx nx run ui:test` green. + +### Phase 11 — Order app menu cards show tag badges + +`MenuProductCard` renders `product.tags` with `TagBadge` under the name, per FR-5. +`MenuItemDetailScreen` shows the resolved variant's `tags` beside the price. + +**Acceptance:** `MenuProductCard` stories for the three cases (Pancong shows `New · Ice Cream`, not +`New`); `MenuListHandler.test.tsx` asserts the badge text for a tagged product. + +### Phase 12 — Order app highlight sections + +`MenuHighlightSection` + `MenuHighlightCard` components (horizontal carousel, variant image → +product image fallback, variant price or starting price). `MenuListScreen` gains a +`highlightSections` prop rendered above the category groups. `MenuListHandler` builds it with +`buildTagHighlights` only when `query` is empty and `selectedCategoryId` is `null` (D7). +`MenuListUsecase`'s `SELECT_ITEM` gains optional `variantId` kept in context; the handler maps it +to that variant's option value ids for `SELECT_PRODUCT.preselectedOptionValueIds` (D6). + +**Acceptance:** `menuList.test.ts` covers `SELECT_ITEM` with and without `variantId`; +`MenuListHandler.test.tsx` covers: sections rendered in `sortOrder` above categories, hidden while +searching or filtering, entries newest-tagged first, and tapping *Pancong · Ice Cream* opens the +sheet with Ice Cream pre-selected; `MenuListScreen` story with two sections. + +### Phase 13 — Order app highlighted-tag chips + +`CategoryChipList` gains an optional `tags` list rendered between **Semua** and the categories, +each chip in its tag colour, with `selectedTagId` / `onSelectTag` props. `MenuListUsecase` gains +`selectedTagId` in context and on `CHANGE_PARAMS`; the reducer clears `selectedCategoryId` when a +tag is selected and vice versa (D16). `MenuListHandler` passes only highlighted tags whose section +is non-empty, and when a tag is selected renders that tag's `buildTagHighlights` entries as a +vertical list of full-width `MenuHighlightCard`s instead of the category groups. +`MenuListScreen`'s `variant` gains a `{ type: 'tagLoaded'; tag; entries }` arm for that view. + +**Acceptance:** `menuList.test.ts` covers selecting a tag clears the category, selecting a category +clears the tag, and **Semua** clears both; `MenuListHandler.test.tsx` asserts the tag chip appears +only for a non-empty highlighted tag and that tapping it shows exactly that tag's entries, newest +first; `CategoryChipList` story with tag chips. + +### Phase 14 — Docs site page + e2e coverage + +A "Tags" page in `docs-site/` (managing tags, assigning to products vs. single variants, how the +order app shows them). `pos-web-e2e`: create a tag, assign it to a product, see the badge in the +transaction item picker. `order-web-e2e`: a highlighted tag's section appears above categories, a +variant card pre-selects its option, and the tag chip filters to that tag. Run locally — e2e runs +post-merge only. + +**Acceptance:** both e2e specs pass locally; docs site builds. + +--- + +## Risks + +| Risk | Mitigation | +| --- | --- | +| Adding an untagged variant to a product-scope-tagged product silently demotes it to per-variant cards. | D4 pre-fill makes it an explicit admin choice in the same form; the assignment screen shows the indeterminate state. | +| Stale "New" tags linger for months. | Manual removal is one untick on the assignment screen; auto-expiry is listed under Deferred. | +| Badge and section clutter if many tags are created. | Max 2 badges per card (D12); only `isHighlighted` tags get sections; carousels (D7). | +| Variant cards without an image look generic. | Fallback to the product image; the image field is in the variant form from phase 2. | +| Extra load on product list reads. | Tags are preloaded on the variant query `resolveAvailability` already runs — one batched preload, no new per-product query. | +| Public API now exposes tag names. | Only name, colour, highlight flag and sort order — nothing internal. Staff-only tags are Deferred. | + +## Out of Scope + +- Automatically computing "Best Seller" from transaction statistics. +- Tag-based discounts, coupons or pricing. +- Tags on receipts, KDS tickets, or transaction statistics (D11). +- Filtering the POS product list by tag. + +## Deferred + +- **Assignment expiry** (`variant_tags.expires_at`, e.g. "New for 30 days"). Worth it once "New" + tags demonstrably go stale; additive column, no model change. +- **Staff-only tags** (`tags.is_public`) for internal markers like "Seasonal stock". +- **Auto best seller**: a scheduled job writing `variant_tags` from sales rank — fits D1 unchanged, + because it only needs to write the same table. + +## Success Criteria + +- The three brief cases render exactly as in the "three cases" tables, in both apps. +- An admin can create a tag and apply it to a product without opening a variant form. +- Share of order-app orders containing at least one highlighted item, measured over the first + month after phase 12, is reported alongside the pre-launch baseline.