chore(openspec): archive replace-mock-api-wave-4
The merge creates the storefront-content capability (two requirements, five scenarios) and updates the mall's home-page requirement so its banner, promotion, quick-link and floor advert content comes from the content API. openspec validate --all --strict stays green at 12 passed / 0 failed.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-17
|
||||
@@ -0,0 +1,54 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See `proposal.md` — Why. The home page renders four local arrays from `~/mock/data` (`pages/index.vue`): `MOCK_BANNERS` and `MOCK_PROMOS` are `{ image, url }`, `MOCK_QUICK_LINKS` adds a bilingual `label` and an inline SVG `glyph`, and each floor's advert art is picked by floor index from `/mock/floor-adv-1..6.svg`. Those assets already exist under `apps/mall/public/mock/`.
|
||||
|
||||
The backend has no content concept: the only shared tables are identity, catalog and orders (`apps/api/migrations/0001..0005`). The admin console already manages shops, users and currencies through `/api/admin/*` with `platform_admin` gating (`apps/api/src/routes/admin.rs`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- The home page renders every element from the backend, with no local content arrays left in `pages/index.vue`.
|
||||
- Content is editable by a platform admin through the API, not only by SQL.
|
||||
- The flip is visually invisible: the seeded content reproduces today's page exactly.
|
||||
|
||||
**Non-Goals:**
|
||||
- No image upload or CDN; rows hold URLs and the seed points at the existing static assets.
|
||||
- No scheduling, targeting or A/B testing.
|
||||
- No content for the seckill, collective or integral pages, nor for stores, brands, coupons, favourites or addresses.
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Four typed tables, not one JSONB content table.**
|
||||
`banners`, `promos`, `quick_links` and `floor_adverts` each get explicit columns, so Postgres enforces the shape and `sqlx::query_as` maps rows without hand-written validation.
|
||||
*Alternative:* a single `content_blocks(kind, payload JSONB)` table is fewer lines, but every read then needs a runtime payload check, which is precisely the `any`-shaped weakness the repo's TS and Rust rules avoid.
|
||||
|
||||
**2. Replace a whole kind at a time instead of per-entry CRUD.**
|
||||
`PUT /api/admin/content/{kind}` takes the ordered list, validates it, and rewrites that kind in one transaction, reindexing positions from array order.
|
||||
*Alternatives:* twelve REST routes for four small lists is a lot of surface, and per-entry CRUD still needs a separate reorder endpoint to express order. Bulk replace makes reordering, insertion and deletion one operation, which is how the content is actually edited.
|
||||
*Consequence to state plainly:* submitting an empty list clears that kind. That is intended (it is how a block is removed), and the endpoint returns the stored list so a caller can confirm what it now holds.
|
||||
|
||||
**3. Floor adverts are an ordered pool, not rows keyed to categories.**
|
||||
The page assigns them to floors in order and wraps, matching today's `index % 6` behaviour.
|
||||
*Alternative:* `floor_adverts.category_id` would let an editor target one specific floor. That is a nicer model, but it changes what the page does today and would leave floors without adverts whenever the tree grows; the pool keeps the current behaviour and the schema smaller.
|
||||
|
||||
**4. The quick-link glyph is stored with the row.**
|
||||
It is a short SVG path string. Keeping it in content means a new quick link renders without a frontend deploy, which is the point of moving this into the database at all.
|
||||
|
||||
**5. A migration seeds content from the assets already in use.**
|
||||
`banner-1..3`, `promo-1..3` and `floor-adv-1..6` become rows, so the page is byte-identical after the flip and the change can be verified as a visual no-op.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [An empty submission silently clears a content kind] → intended (decision 2); the naive admin UI should confirm, and the response echoes the stored list.
|
||||
- [Content rows point at frontend asset paths] → temporary coupling, called out in the non-goals; a real upload capability replaces the URLs later without touching the schema.
|
||||
- [Four extra queries on every home render] → negligible at this size; if it ever matters, the four reads collapse into one query.
|
||||
- [The home page loses its content if the content API is down] → the page renders the remaining blocks rather than failing (a spec scenario), and the fixed-data rollback still works because the mock client keeps serving the same four lists.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Ship the migration and the public read; nothing is reading it yet.
|
||||
2. Flip `pages/index.vue` to the content API and drop the three mock arrays in the same commit as the admin surface, so content is never live-but-uneditable.
|
||||
3. Verify the page is visually unchanged, then verify rollback with the fixed-data adapter.
|
||||
4. Rollback: revert `pages/index.vue`. The content tables are additive and harmless if left in place.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Every domain that had an API is now live, but the home page still renders from `~/mock/data`: the carousel, promotion tiles, quick-link strip and floor advert art are local arrays. They are the last thing between the storefront and backend-driven content, and nobody can change them without a deploy.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add four content kinds behind one public read: `GET /api/content/home` returns banners, promos, quick links and floor advert art, ordered and active-only.
|
||||
- Store them in typed tables rather than one JSONB blob, so the database enforces each shape.
|
||||
- Seed them from the assets the page already uses (`banner-1..3`, `promo-1..3`, `floor-adv-1..6`), so the home page looks unchanged while becoming data-driven.
|
||||
- Manage them with a small admin surface: `GET /api/admin/content` plus `PUT /api/admin/content/{kind}`, replacing one ordered list transactionally. Ordering falls out of array order, and four lists do not need twelve endpoints.
|
||||
- Point `pages/index.vue` at the content API and delete the three mock arrays. The quick-link glyph stays in the row: it is a short SVG path, so a new link renders without a frontend deploy.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `storefront-content`: the storefront's editable marketing content — home banners, promotion tiles, quick links and floor advert art — read publicly and written by platform admins.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `frontend-mall`: the home page sources its banner, promotion, quick-link and floor advert content from the content API instead of local constants.
|
||||
|
||||
## Impact
|
||||
|
||||
A new migration and `apps/api/src/routes/` module; `packages/shared/src/{api,types}.ts`; `apps/mall/pages/index.vue`. The shared contract change means all three frontends rebuild.
|
||||
|
||||
## Non-goals
|
||||
|
||||
No image upload: rows reference URLs, seeded with the existing `/mock/*.svg` assets. No content for the seckill, collective or integral pages, and none for stores, brands, coupons, favourites or addresses — each stays mock until its own wave. No scheduling, A/B testing or translated artwork.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Mock PC home page
|
||||
The mall home page SHALL render a hero row composed of a pinned 240px category sidebar on the left and a hero carousel filling the remainder of the 1200px grid, both 450px tall and occupying layout space (not overlaid). The sidebar SHALL list the catalog's top-level categories with up to three child links each; hovering a top-level category SHALL expand the mega-menu panel to the right over the carousel. Banner images SHALL render at fixed 450px height, center-cropped horizontally to the narrower carousel width. Below the hero, the page SHALL render a six-item quick-link strip with promotion tiles and bilingual product floors, where each floor's products come from the catalog API and the banners, promotions, quick links and floor advert art come from the content API.
|
||||
|
||||
#### Scenario: shopper lands on home
|
||||
- **WHEN** `/` loads
|
||||
- **THEN** the category sidebar is visible to the left of the carousel without any hover or click, the carousel renders center-cropped banners at 450px height, and the quick links, promotions and every non-empty product floor render, with floor products from the catalog API and the marketing content from the content API
|
||||
|
||||
#### Scenario: sidebar stays while scrolling
|
||||
- **WHEN** a shopper scrolls the home page beyond 200px
|
||||
- **THEN** the category sidebar remains rendered in the hero row and does not auto-hide
|
||||
|
||||
#### Scenario: expand a category
|
||||
- **WHEN** a shopper hovers a top-level category in the pinned sidebar
|
||||
- **THEN** the mega-menu panel expands to the right, overlaying the carousel with that category's child and grandchild links from the catalog API
|
||||
|
||||
#### Scenario: home renders with no content published
|
||||
- **WHEN** the content API returns empty lists
|
||||
- **THEN** the page still renders its category sidebar and product floors instead of failing
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
The storefront's editable marketing content: the home page's banners, promotion tiles, quick links and floor advert art, written by platform admins and read publicly.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public home content
|
||||
`GET /api/content/home` SHALL be readable without authentication and SHALL return the active home content as four ordered lists: `banners`, `promos`, `quick_links` and `floor_adverts`. Each entry SHALL carry what the storefront renders: an image URL and a destination URL for banners and promos, a bilingual label and an inline SVG glyph for quick links, and an image URL for floor adverts, whose destination is the floor's own category. Entries flagged inactive SHALL never appear, and each list SHALL be ordered by its stored position.
|
||||
|
||||
#### Scenario: only active content is served
|
||||
- **WHEN** a shopper loads the home content while one banner is marked inactive
|
||||
- **THEN** that banner is absent from `banners`, and the remaining entries keep their stored order
|
||||
|
||||
#### Scenario: an empty kind still answers
|
||||
- **WHEN** a kind has no active entries
|
||||
- **THEN** the response returns an empty list for it rather than omitting the key or failing, so the page renders without that block
|
||||
|
||||
### Requirement: Content management
|
||||
Platform admins SHALL read all home content with `GET /api/admin/content` and replace one kind at a time with `PUT /api/admin/content/{kind}`, where `kind` is `banners`, `promos`, `quick-links` or `floor-adverts`. A replacement SHALL validate every entry, apply as a single transaction, and reindex positions from the submitted order. A rejected entry SHALL leave the stored content exactly as it was. Writing SHALL require the `platform_admin` role.
|
||||
|
||||
#### Scenario: replace reorders and reindexes
|
||||
- **WHEN** an admin submits the same three banners in a different order
|
||||
- **THEN** a subsequent public read returns them in the new order
|
||||
|
||||
#### Scenario: an invalid entry is rejected atomically
|
||||
- **WHEN** an admin submits a list whose second entry is missing its image URL
|
||||
- **THEN** the request fails and the previously stored list is unchanged
|
||||
|
||||
#### Scenario: non-admins cannot write
|
||||
- **WHEN** a signed-in customer or shop owner sends a replacement
|
||||
- **THEN** the API refuses the write
|
||||
@@ -0,0 +1,32 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Schema and seed
|
||||
|
||||
- [x] 1.1 Add a migration creating `banners`, `promos`, `quick_links` and `floor_adverts` with explicit columns (`image`, `url`, `position`, `active`, plus `label`/`glyph` on quick links); verified the tables exist after `cargo run -p vmall-api`
|
||||
- [x] 1.2 Seed the same migration from the assets the page uses today, matching `pages/index.vue`'s arrays; verified `GET /api/content/home` returns 3 banners, 3 promos, 6 quick links and 6 floor adverts. The seeded destinations are valid routes (`/seckill`, `/collective`, `/integral`, `/search?sort=price&order=desc`) rather than the mock's dangling `?category=c1` ids and its `sort=sales`, which the catalog API rejects
|
||||
|
||||
## 2. Shared contract
|
||||
|
||||
- [x] 2.1 Add `HomeBanner`, `HomePromo`, `HomeQuickLink`, `FloorAdvert`, `HomeContent`, `ContentKind` and `ContentInputByKind` to `packages/shared/src/types.ts`, with the quick-link label as `LocalizedText`; verified all three frontends build
|
||||
- [x] 2.2 Add `getHomeContent()` and `admin.getContent()` / `admin.replaceContent(kind, list)` to the `ApiClient` interface and `createApi`, and give the fixed-data client matching implementations so the rollback path keeps serving the current arrays. Also registered a `content` domain in the per-domain switch (`plugins/api.ts` and `nuxt.config.ts`) — missed on the first pass and caught by the browser check, where the promos still rendered the mock hrefs because the new method had no live pick
|
||||
|
||||
## 3. Public content read
|
||||
|
||||
- [x] 3.1 Add `GET /api/content/home` returning the four active, position-ordered lists, with no authentication; verified it answers with the seeded counts and omits a row once it is marked inactive
|
||||
- [x] 3.2 Return an empty list for any kind with no active entries rather than omitting the key; asserted by `home_content_is_public_and_ordered`
|
||||
|
||||
## 4. Admin content management
|
||||
|
||||
- [x] 4.1 Add `GET /api/admin/content` returning every kind including inactive rows, gated to `platform_admin`; verified a customer and a shop owner are refused with 403
|
||||
- [x] 4.2 Add `PUT /api/admin/content/{kind}` replacing one kind transactionally, validating each entry and reindexing positions from the submitted order; verified a reordered submission reads back in the new order, and that the rendered page follows
|
||||
- [x] 4.3 Verify an invalid entry (missing `image`, or a quick-link label without `zh`) fails without changing the stored list
|
||||
|
||||
## 5. Mall home page
|
||||
|
||||
- [x] 5.1 Point `pages/index.vue` at `getHomeContent()`, delete the three mock arrays from the page, and render the floor advert pool by floor order; verified the page still renders its sidebar and floors, and that a floor renders no advert rather than a broken image when the pool is empty
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- [x] 6.1 Run the mall, shop-admin and admin builds, since the shared contract changed; all three pass, and `cargo test -p vmall-api` is green at 23 tests
|
||||
- [x] 6.2 Compared the home page against the pre-change render: 3 banners, 6 quick links, 3 promo tiles and floor adverts 1-6 across 6 floors, visually unchanged. Reordering banners and marking one inactive through the admin API changed the rendered carousel accordingly, then the seed was restored
|
||||
- [x] 6.3 Verified the rollback: with every domain on fixed data and the backend stopped, the home page still renders all four blocks with no console errors
|
||||
Reference in New Issue
Block a user