Skoolsewa - Ecommerce Docs
Developer ResourcesArchitecture

Storefront Integration

How skoolsewa-frontend consumes the API — CMS pages, cache tags, banners, analytics and account deletion — and the contracts that must not drift.

Storefront Integration

The customer storefront (skoolsewa-frontend) holds no database. Everything it renders comes from this API, and the seams between the two repos are where the expensive failures live: neither compiler can see across the boundary, and almost every mismatch degrades silently rather than erroring.

This page records the contracts as they stand, and — more usefully — the ways each one has already been got wrong.

1. Cache invalidation has two producers, not one

Both the API and the admin panel POST to the storefront's /api/revalidate. It verifies an HMAC-SHA256 over `${timestamp}:${body}`, sent as x-signature with x-timestamp, and checks freshness before the signature so a captured request cannot be replayed indefinitely.

ProducerSecret it signs withMust equal
APINEXT_REVALIDATE_SECRET (backend .env)NEXT_REVALIDATE_SECRET (storefront)
AdminSTOREFRONT_REVALIDATE_SECRET (admin .env.local)the same storefront value

A mismatch on either side is silent. The storefront answers a bare 401 with no detail — by design, since the route can purge the entire site's cache — and the caller logs and continues, because a mutation that already committed must never 5xx over a cache.

This was live in the local environment: the API's secret matched and the admin's did not, so every admin-triggered purge was rejected. The only evidence anywhere was one {"event":"revalidate_rejected","reason":"invalid_signature"} line in the storefront log and one storefront cache invalidation failed in the admin's. Both apps looked healthy.

The tags the storefront actually subscribes to

STOREFRONT_TAG in cache-invalidation.tags.ts is the authoritative list, and it exists because revalidateTag is matched by string equality across two repositories — a tag nobody subscribes to is not invalidation, it is a log line.

banners · blog · catalog · content · faq · page-seo · products · reviews · runtime-config

Every consumed domain emits its coarse tag alongside its granular ones. That is deliberate and additive: a rename needs both repos to deploy together, and a half-done rename must never leave zero tags matching. cache-invalidation.tags.spec.ts asserts the mapping per domain and names the domains that deliberately reach no consumer, so adding one stays a decision rather than an oversight.

emi, promotion and shipping are the remaining unconsumed three. banner left that list when the storefront grew a banner surface.

2. CMS pages — ten keys, and what renders each

The storefront resolves every page through getContentPage(pageKey) and treats a failure as "nothing authored": each resolver catches, returns undefined, and every field falls back to a fixture. A CMS outage must never cost a page its content — the header and footer render on every route, so an uncaught throw there is a site-wide 500.

Page keySectionsStorefront surface
homehero, how_it_works, faq_intro, related_blogs_intro, cta_bannerapp/page.tsx + _lib/home-content.ts
abouthero, how_it_works, about_stats, why_choose, our_missionabout/_lib/about-content.ts
faqhero, faq_introfaq/_lib/faq-content.ts
contacthero, contact_info, contact_formcontact/_lib/contact-content.ts
blogsheroblog/page.tsx
privacy_policydocument, data_flow_summaryprivacy-policy/page.tsx
return_policydocumentreturn-policy/page.tsx
terms_of_servicedocumentterms-of-service/page.tsx — seeded with copy that states it is a draft, in the body and not only in a comment, so a CMS outage cannot fall back to text that reads as binding
global_headerannouncement_bar, contact_info, quick_linkscomponents/layout/header-section.tsx
global_footerbrand_blurb, quick_links, contact_info, legal_links, copyrightcomponents/layout/footer-section.tsx

Sections render only when authored. A page with nothing in the CMS looks exactly as it did before the CMS existed, which is what makes adding a renderer safe.

contact_info is a union, and both arms use one key

The footer/header arm is {title, contacts[], address?, openingHours?}; the contact page's is {email, phone, responseTime}. Both validate on their own page, so a consumer must require the fields of the arm it wants — safeParse on the wrong arm is what stops the footer's heading being rendered where a phone number belongs.

address and openingHours were added for the footer. The storefront derives the store map's URL from whichever address won, so authoring the address moves the pin: an address and a map that disagree is worse than either being stale.

The CMS URL validator rejects tel: and mailto:, so a contact row's href can never be stored. Every surface builds it from the label instead — digits and a leading + only, so "+977 1 5555555" still produces a dialable link.

quote and story_blocks are reserved

Both are declared in CONTENT_SECTION_KEYS and assigned to no page. That is deliberate, and content-schema.registry.spec.ts says so and asserts it. They are not orphans and need no cleanup.

3. Banners

GET /api/mobile/banners/serve/:slug returns {placement, banners[], slots[], generatedAt}.

Prefer ctaUrl. The API resolves it at serve time from the target's current slug, so a client must never build a URL from target.slug or cache one.

A child category's URL nests under its parent. The storefront routes exactly two category shapes — /categories/[parent] and /categories/[parent]/[child] — and cannot derive a parent from a child slug. buildTargetUrl used to emit a flat /categories/{slug} for every category, so every banner targeting a child category linked to a 404. Nothing could see it: the response carried a well-formed URL and only a click on a live campaign revealed it.

The serving query now left-joins the parent and its current slug. A category nested deeper than one level resolves its parent to null on purpose — the storefront has no page for it, and naming its immediate parent would build a URL that still 404s while looking more correct.

Tracking

POST /banners/events/impression and /click both require bannerPublicId and placementPublicId (@IsUUID() @IsNotEmpty()), with campaignPublicId optional. The placement id belongs to the placement, not the banner, so it has to be threaded down from the serve response.

The global pipe runs forbidNonWhitelisted, and the storefront's BFF route swallows failures by design — so a partial body is a 400 nobody ever sees, and an event nobody ever counts. The BFF refuses to forward unless both ids are present.

3a. Series and tag landing pages

/series/[slug] and /tags/[slug] consume GET /catalog/series/:slug and GET /catalog/tags/:slug for the page's own copy, then filter the product list with seriesSlug or tags respectively.

Both detail endpoints return the entity only — no products — so each page makes two calls. Both also accept a historical slug, which is why each page canonicalises to the slug that comes back rather than the one in the URL: a renamed series stays reachable at its old address without two URLs both claiming to be the page.

tags is AND on the API. A tag page therefore applies exactly one — passing two would narrow the page below what its own URL promises. Everything else on the page is a facet.

A series replaces its brand filter rather than adding to it. A series is strictly narrower, and the brand is implied by the products that come back.

Series pages are gated at two products. Nine of the fifteen series in the catalogue hold exactly one, and a listing of a single item is a worse version of that item's own product page. The storefront asks for exactly two and counts what comes back, 404s below the threshold, and uses the same helper to decide what enters the sitemap — so the page and the sitemap cannot disagree. Tags are not gated: there are six and the smallest holds six products.

4. Checkout timing

CheckoutSessionDto carries both expiresAt and expiresInSeconds. Prefer expiresInSeconds: it is measured on the server's clock, while subtracting expiresAt from a device clock inherits however wrong that device is. A phone five minutes fast would show a session expired while the API still accepts it.

The session TTL is 5 minutes and the payment window 25 (CHECKOUT_HOLD_TTL_SECONDS is their sum, by construction). Five minutes is the deadline to press Pay, not to finish paying — a session at a gateway is exempt from expiry.

POST /checkout/{id}/cancel is refused once the status is payment_in_progress: by then the money may already be moving and cancelling is the wrong verb. A consumer should not offer a control the API will refuse.

See Also

  • Content — page keys, section schemas and the admin's mirror
  • Checkout — session lifecycle and close reasons