docs(openspec): plan wave 5, the store directory

Planning only: proposal, delta specs, design and tasks for replacing the
store surfaces. No code yet.

The batch adds a public shop read (profile + products) so the store directory,
store home and product-page store card stop reading MOCK_STORES, and so the
cart, payment and order surfaces can name a shop instead of printing "Shop".

Decisions recorded in the design:

- a `shop_profiles` table beside `shops`, so the existing admin and
  shop-admin contracts are untouched
- the four store scores stay stored profile values, because there is no review
  model and deriving them would dress fabricated numbers as computed ones
- `distanceKm` is dropped rather than faked: there is no geo model behind it
- profiles are filled by seed-demo.mjs, since they hang off shops that script
  creates
- order surfaces resolve names from one cached shop read rather than growing a
  denormalised shop name on every order payload

openspec validate --strict passes and the change is ready to apply.
This commit is contained in:
2026-09-17 16:54:22 +00:00
parent f6ddfd21cb
commit 51f8bb7c1d
6 changed files with 177 additions and 0 deletions
@@ -0,0 +1,57 @@
# Design
## Context
See `proposal.md` — Why. Today the store surfaces read `MOCK_STORES` (`pages/stores/index.vue`, `pages/stores/[id].vue`, and the store card in `pages/goods/[id].vue`), whose shape is `{ id, slug, name, company, logo, banner, region, address, distanceKm, rate{score,agree,service,speed}, notice, afterSale }`.
The live `shops` table holds only `(id, name, slug, status, created_at)` (`apps/api/migrations/*`), and `Shop` is the shared model returned by `create_shop` and `set_shop_status` to both the admin console and shop-admin. Products already carry `shop_id`, and `listProducts({ shop_id })` already filters by it.
After wave 3 the cart line carries `shop_name`, but `pages/checkout/pay.vue` and `pages/user/orders/index.vue` still resolve a name through `storeById` and fall back to a generic "Shop" label, because an order carries only `shop_id`.
## Goals / Non-Goals
**Goals:**
- The store directory, store home and product-page store card render real shops with real products.
- Order surfaces show real shop names.
- No field is invented to fill a UI slot.
**Non-Goals:**
- No reviews model. The four scores are stored profile values set by the platform, not derived ratings.
- No geo data: a shop has no coordinates, so distance cannot be computed.
- No merchant self-service profile editing, and no work on the seckill, collective or integral pages.
## Decisions
**1. A `shop_profiles` table rather than new columns on `shops`.**
Keeps identity (`name`, `slug`, `status`) separate from marketing content, and leaves the `Shop` model — and therefore the admin and shop-admin contracts — untouched. The public read composes the two with one join.
*Alternative:* widening `shops` would ripple through both consoles' payloads for data neither of them uses.
**2. The four scores are stored profile values.**
The store pages show a score row and the product page shows a store rating. Nothing derives them: there is no review model, and inventing a formula over order counts would dress fabricated numbers as computed ones. Storing them as platform-set profile fields is the honest option, and it is named as such in the non-goals so nobody mistakes them for review aggregates.
**3. Distance is dropped, not faked.**
`distanceKm` sorts the directory and prints "x.x km away". There is no user location and no shop coordinates, so any number is fiction. The distance column and its sort option go, and the tracker records it.
*Alternative:* store a distance per shop — meaningless without a reference point.
**4. `seed-demo.mjs` fills the profiles, not a migration.**
Profiles hang off shops that the seed script creates, so a migration cannot reference them. Schema stays in the migration; demo profile content goes where the shops are made.
*Consequence to accept:* the mall must render a shop with no profile row rather than assume one exists, which the spec makes explicit.
**5. Order surfaces resolve names from one cached public read.**
`pages/checkout/pay.vue` and `pages/user/orders/index.vue` share a `useAsyncData` key over `GET /api/shops` and map `shop_id` to a name, so no order or shipment payload grows a denormalised shop name.
*Alternative:* add `shop_name` to every order view — more backend surface, and it would duplicate data the shop read already owns.
## Risks / Trade-offs
- [The directory now lists the four real demo shops, not the mock's fictional ones] → expected: this is real data. The layout is unchanged; the names and logos differ from the mock.
- [Removing the distance sort removes a UI feature] → deliberate and recorded; it can return with a geo model.
- [Stored scores could be read as real ratings] → named as profile values in the capability spec and the non-goals.
- [A shop without a profile must not render blank boxes] → the spec requires the fields to be absent rather than invented, and the pages guard each block.
- [One extra request on cart, payment and order pages] → cached under a single key, and it replaces lookups that currently miss and print a placeholder.
## Migration Plan
1. Ship the migration (schema only) and the two endpoints; nothing reads them yet.
2. Teach `seed-demo.mjs` to set each shop's profile, then re-run it.
3. Flip the store pages, the store card and the order surfaces in one commit.
4. Rollback: revert those pages. `shop_profiles` is additive and harmless if left in place.