Happy House - Ecommerce Docs
Developer ResourcesArchitecture

Storefront Integration

How happy-shop-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 (happy-shop-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. Analytics — the ids reach the page, the CSP has to let them load

GET /config/runtime/public serves gtmId, gaMeasurementId and metaPixelId. The storefront validates each against a pattern before interpolating it into a script tag and renders nothing for a null.

That was all correct and the feature was still completely inert. Both storefront CSPs allowed script-src 'self', so the moment the GTM bootstrap injected <script src="https://www.googletagmanager.com/gtm.js"> the browser refused it. The beacons had nowhere to go either (connect-src 'self'), and the pixel fallbacks were blocked by img-src.

Nothing throws, no server log records it, the page renders perfectly, and the only evidence is a CSP violation in the browser console.

Browsers intersect every policy they are given, so the vendor origins have to be present in the static policy (next.config.ts) and the per-request one (lib/auth/csp.ts) — a host in one and missing from the other is allowed nowhere.

5. Account deletion is a five-endpoint lifecycle

MethodPathAuthBody
POST/auth/account-deletion/requestcustomer
DELETE/auth/account-deletion/cancelcustomer
GET/auth/account-deletion/statuscustomer
POST/auth/account-deletion/recoverpublic{email}
POST/auth/account-deletion/recover/confirmpublic{email, otp}

The two recovery endpoints are public by necessity — an account pending deletion may already be unable to sign in, which is exactly when it needs recovering. That makes them a probe for which addresses exist, so the success message must be identical whether or not the address is registered.

Recovery is a 6-digit emailed OTP, not a magic link. ConfirmAccountRecoveryDto declares email and otp (@MinLength(6)) and nothing else. A storefront build that assumed a ?token= in the URL had every confirmation rejected with property token should not exist, email must be a string, otp must be longer than or equal to 6 characters — a 400 the UI showed as a generic failure, with the real reason visible only in this API's log.

GET .../status returns {status, deletionScheduledAt?, deleteBlockUntil?} where status is active | pending_deletion | deleted. Both dates are nullable and omitted entirely for an active account, and the consumer keeps them as raw ISO strings — they cross a server/client boundary to render a countdown, and a Date does not survive that intact.

6. 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

  • Cache invalidation — the tag registry and the domains it covers
  • Content — page keys, section schemas and the admin's mirror
  • Checkout — session lifecycle and close reasons