feat(mall): serve home marketing content from the API

Wave 4 of replacing the fixed-data mock adapter, and the first capability the
mall never had a backend for: the home page's banners, promo tiles, quick links
and floor advert art move out of local arrays.

- four explicit tables (`banners`, `promos`, `quick_links`, `floor_adverts`)
  rather than one JSONB payload table, so Postgres enforces each shape
- a migration seeds them from the assets the page already rendered, so the flip
  is visually a no-op. Destinations are real routes now: the mock's promo links
  pointed at dangling `?category=c1` ids and its first banner used `sort=sales`,
  which the catalog API rejects
- `GET /api/content/home` is public and returns the four active, ordered lists,
  always including a key so a page can render a missing block
- `GET /api/admin/content` and `PUT /api/admin/content/{kind}` let a platform
  admin read everything and replace one kind transactionally, with positions
  reindexed from the submitted order and a rejected list changing nothing
- the mall's fixed-data adapter learns `getHomeContent`, and a `content` domain
  joins the per-domain switch so the rollback path still renders the page

Verified: 23 backend tests green including six new content tests; all three
frontends build; the home page renders the same four blocks as before, an admin
reorder and deactivation change the rendered carousel, and the fixed-data
rollback renders every block with the backend stopped.

OpenSpec change: openspec/changes/replace-mock-api-wave-4
This commit is contained in:
2026-09-17 16:48:10 +00:00
parent 2136a48fbe
commit 104737e4e1
17 changed files with 866 additions and 15 deletions
+15
View File
@@ -3,7 +3,10 @@ import type {
AuthTokens,
Cart,
Category,
ContentInputByKind,
ContentKind,
Currency,
HomeContent,
Invoice,
InvoiceKind,
LocalizedText,
@@ -160,6 +163,8 @@ export interface ApiClient {
kind: InvoiceKind,
): Promise<Invoice>;
listMyInvoices(): Promise<Invoice[]>;
/** Public home-page marketing content: banners, promos, quick links, floor adverts. */
getHomeContent(): Promise<HomeContent>;
shop: {
getMyShop(): Promise<Shop>;
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
@@ -192,6 +197,13 @@ export interface ApiClient {
listCurrencies(): Promise<Currency[]>;
upsertCurrency(body: CurrencyUpsertBody): Promise<Currency>;
setRate(code: string, rateToBase: string): Promise<Currency>;
/** Every content kind, inactive rows included. */
getContent(): Promise<HomeContent>;
/** Replaces one kind from an ordered list; positions follow the array order. */
replaceContent<K extends ContentKind>(
kind: K,
items: ContentInputByKind[K],
): Promise<HomeContent>;
};
}
@@ -224,6 +236,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
requestInvoice: (orderId, title, taxNo, kind) =>
r("POST", `/orders/${orderId}/invoice`, { title, tax_no: taxNo, kind }),
listMyInvoices: () => r("GET", "/invoices"),
getHomeContent: () => r("GET", "/content/home"),
shop: {
getMyShop: () => r("GET", "/shop/profile"),
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
@@ -259,6 +272,8 @@ export function createApi(opts: ApiClientOptions): ApiClient {
setRate: (code, rateToBase) => r("PUT", `/admin/currencies/${code}/rate`, {
rate_to_base: rateToBase,
}),
getContent: () => r("GET", "/admin/content"),
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
},
};
}
+55
View File
@@ -188,3 +188,58 @@ export interface Paged<T> {
export interface ApiErrorBody {
error: { code: string; message: string };
}
// ---- storefront content ----
/** The home page's editable marketing content. See the storefront-content spec. */
export interface HomeBanner {
id: string;
image: string;
url: string;
position: number;
active: boolean;
}
export interface HomePromo {
id: string;
image: string;
url: string;
position: number;
active: boolean;
}
export interface HomeQuickLink {
id: string;
label: LocalizedText;
url: string;
/** Inline SVG path data, 24x24 viewBox. */
glyph: string;
position: number;
active: boolean;
}
/** Ordered pool; the home page assigns these to floors in order and wraps. */
export interface FloorAdvert {
id: string;
image: string;
position: number;
active: boolean;
}
export interface HomeContent {
banners: HomeBanner[];
promos: HomePromo[];
quick_links: HomeQuickLink[];
floor_adverts: FloorAdvert[];
}
export type ContentKind = "banners" | "promos" | "quick-links" | "floor-adverts";
/** Request payload per kind for `replaceContent`; ids and positions are assigned server-side. */
export interface ContentInputByKind {
banners: { image: string; url: string; active?: boolean }[];
promos: { image: string; url: string; active?: boolean }[];
"quick-links": { label: LocalizedText; url: string; glyph: string; active?: boolean }[];
"floor-adverts": { image: string; active?: boolean }[];
}