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:
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user