Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesMinisitesMinisites — Behavior

Minisites — Behavior

Request flow

  1. Visitor hits https://<host>/<slug> (slug is empty for the home page).
  2. Astro’s catch-all apps/minisites/src/pages/[...slug].astro reads the x-forwarded-host or host header.
  3. It calls GET ${INTERNAL_API_URL}/minisites/resolve?host=<host> on the Taikan API.
  4. The API joins minisite_content by custom_domain or subdomain and returns the published payload plus a platformData block (live programs, sessions, plans, courses, coaches, events) and the org metadata.
  5. If isPublished is false or publishedContent is null, the Astro page returns a 404.
  6. The page resolves the URL slug to a page in publishedContent via findPage(content, slug) (apps/minisites/src/lib/pages.ts). Unknown or disabled slug returns a 404 (“Page not found”).
  7. Otherwise Astro renders the layout with the matched page’s sections + live data and serves the response with Cache-Control: public, s-maxage=120, stale-while-revalidate=300.

Multi-page routing

Since FIT-209 a minisite is a fixed page set rather than a single page:

SlugPage kindDefault
/homeAlways enabled.
/coursescoursesOwner-toggleable (off by default).
/workshopsworkshopsOwner-toggleable (off by default).
/aboutaboutOwner-toggleable (off by default).
/contactcontactOwner-toggleable (off by default).
  • The catch-all reads the slug and renders the matching page’s sections. / maps to home.
  • findPage only matches pages with isEnabled !== false; a disabled or unknown slug 404s.
  • /courses/[id] still serves the per-course detail page (separate route, unchanged).
  • Nav links are built by getNavLinks(content): when more than one page is enabled the layout renders page links (Home first, then each enabled page that has showInNav), otherwise it returns an empty list and the layout falls back to in-page #anchor section nav (legacy single-page behavior).

Legacy single-page back-compat

Older rows only carry content.sections (no pages). On read these are shimmed into a single Home page:

  • Shared helper getMinisitePages(content) in @taikan/shared returns content.pages when present, otherwise wraps content.sections as one home page.
  • The renderer mirrors this in apps/minisites/src/lib/pages.ts (getPages) so the Astro build stays decoupled from the @taikan/shared build.
  • The API service ensurePages() upgrades rows in place: it shims legacy sections into Home, backfills any missing default pages (disabled), and backfills missing section types into the Home page. content.sections is kept mirrored to the Home page’s sections for back-compat.

Host resolution

The API attempts matches in this order:

  1. Exact custom_domain match (case-insensitive).
  2. Exact subdomain match against <slug>.taikan.fit.

If neither matches, the resolve endpoint returns 404. The minisite app surfaces a generic “Site not found” response.

Sections

Each page renders its own ordered sections list. The [...slug].astro page composes from a fixed component map:

SectionSource dataHidden when
heroEditor (eyebrow, heading, subheading, background image, CTA)Never (always renders).
aboutEditor + org description + image gallery + valuesAll sources empty.
classesLive platformData.programs[]Org has no active programs.
scheduleLive platformData.upcomingSessions[]Org has no upcoming sessions.
pricingLive platformData.plans[]Org has no active plans.
coursesLive platformData.courses[]Org has no active courses.
trainersLive platformData.coaches[]Org has no active coaches.
eventsLive platformData.events[] (from minisite_events)Org has no published upcoming events.
contactEditor (title, map, contact form, whatsapp)Never.
galleryEditor (image list)Never (renders empty grid).
testimonialsEditorNever.
faqEditorNever.
socialEditor (Instagram / TikTok / Facebook / YouTube links + curated post thumbnails)Never.
servicesEditor (cards: title, blurb, image, optional CTA)All cards empty (self-guards, renders nothing).

Sections additionally honor an isEnabled flag in the editor; owners can suppress a section entirely. Auto-populated sections (classes, schedule, pricing, courses, trainers, events, contact) are also hidden when their live data source is empty, computed in [...slug].astro via hasDataMap.

Social section

social is a manual section. Owners enter channel links (Instagram handle/URL, TikTok, Facebook, YouTube) plus a list of curated post thumbnails (posts[] of { imageUrl, link? }). A true Instagram embed needs the Graph API, so thumbnails are curated by hand. Component: apps/minisites/src/components/sections/Social.astro.

Services section

services is a manual section: a free-form grid of offering cards the owner authors directly (services[] of { title, description?, imageUrl?, ctaText?, ctaAction? }). Unlike classes (which auto-populates from operational programs), services is pure marketing content with no platform-data dependency, for presenting offerings that aren’t modeled as programs/plans/courses (online coaching, personal training, consulting, lead-capture offerings). Each card’s optional CTA links anywhere via safeUrl (a page anchor like #contact, a route, or an external URL). The section self-guards: it renders nothing when no card has a title, so an enabled-but-unfilled section never leaves a blank block. Component: apps/minisites/src/components/sections/Services.astro.

Events section

events is auto-populated from the new minisite_events table. The resolve endpoint returns upcoming published events in platformData.events[]; the section renders them as dated cards. Events are not purchasable; each card’s CTA has two modes:

  • External registration — when the event has a registrationUrl (set in the dashboard Events manager), the CTA links out to it (safeUrl-sanitized, new tab). For events whose registration lives elsewhere (e.g. a partner academy).
  • Event lead capture (default) — the CTA opens an on-site modal (EventLeadModal.astro, one instance per page with a visible events section) pre-titled with the event; the submission posts through the same /api/lead proxy as the contact form with minisiteEventId. The API validates the id against the org’s events and buckets the lead into a per-event lead_campaigns hook (channel='minisite', externalRef='event:<id>', named after the event title) — same auto-mint mechanism as the popup’s lead-popup and contact form’s contact-form refs, so the CRM board/list can filter sign-ups per event. Progressive enhancement: without JS the CTA falls back to navigating to the Contact page (/contact when enabled, else the in-page #contact anchor).

Component: apps/minisites/src/components/sections/Events.astro.

Theme

The whole template was restyled in FIT-209 to a premium editorial look (charcoal + copper + cream, heavy display headings, referencing erezmotion.com). The token layer lives in apps/minisites/src/layouts/modern.astro.

The theme block carried in minisite_content.theme (and overridable inside publishedContent.theme) is:

{ "primaryColor": "#b86a35", "secondaryColor": "#f4efe7", "fontFamily": "inter", "headingFontFamily": "playfair", "buttonStyle": "rounded", "cornerStyle": "soft", "sectionDensity": "spacious" }
  • primaryColor is the accent (buttons, eyebrows, links); secondaryColor is the light surface tint. The default is warm copper (#b86a35 / #f4efe7).

  • headingFontFamily is an optional separate display font for headings; it falls back to fontFamily. Font keys are resolved through the same fontMap as fontFamily (inter | poppins | roboto | montserrat | open-sans, each with a 'Heebo' Hebrew-glyph fallback appended). Its Google Fonts <link> is only added when it actually differs from fontFamily — no wasted request when an org picks the same family for both.

  • cornerStyle (sharp | soft | round, default soft) and sectionDensity (compact | normal | spacious, default spacious) are wired into the layout (apps/minisites/src/layouts/modern.astro) as of the FIT-287 design rework — previously stored and editable in the ThemeEditor but silently ignored by the renderer. buttonStyle (rounded/pill/square) stays the sole driver of --btnRadius on primary/outline buttons; cornerStyle only reaches the broader surface-radius scale (cards, pills, badges, form fields) so the two settings never fight over the same element:

    cornerStyle--radius-card--radius-btn--radius-pill
    sharp0.75rem0.5rem0.75rem
    soft (default)1.25rem0.875rem999px
    round2rem1.25rem999px
    sectionDensity--section-pad
    compact4.5rem
    normal6.5rem
    spacious (default)8.5rem
  • Tokens flow into the layout via one define:vars block (colorPrimary, colorSecondary, fontFamily, headingFontFamily, btnRadius, radiusCard, radiusBtn, radiusPill, sectionPad) that the :root CSS then derives everything else from (--accent, --accent-soft, --font-display, --radius-*, --section-pad, --shadow-card, --shadow-float, etc.).

  • The layout normalizes a legacy primary color: '', #000, #000000, black, #0f172a are remapped to the copper default so pre-revamp rows pick up the new template; any other custom color is honored as-is.

Design rework (FIT-287)

FIT-287 restyled the template again on top of FIT-209’s editorial base, adopting a strain.co.il-style structure and motion system while keeping the warm ivory + copper identity. Unflagged — one global template, applied to every published site on deploy. Scope was modern.astro + all 14 section components + courses/[id].astro; no client framework, no new dependency (Astro’s assetsInlineLimit: 0 keeps the one motion script same-origin under the minisite CSP script-src 'self').

  • Nav — floating pill (position: fixed, own surface + shadow + backdrop-filter: blur), logo, page/anchor links, one CTA. The CTA prefers joinUrl when present (?utm_source=minisite&utm_medium=nav appended) and falls back to the contact path (/contact on multi-page sites with a Contact page, #contact anchor otherwise) — same fallback logic as every other CTA in this doc.
  • Imagery — hero background, About images, and course-detail hero images all get a shared desaturated treatment (filter: saturate(.82) sepia(.06) contrast(1.02)) so owner photography reads as part of the ivory + copper palette instead of clashing with it.
  • Cards, sections, footer, FAQ accordion — all now derive from the cornerStyle/sectionDensity tokens above. FAQ renders as a native <details> accordion (no JS, styleable marker). The footer (modern.astro) is a dark, comprehensive block: brand + description, a nav-links column (title-less — it reuses the existing nav link set, no separate heading was added), a contact/social column (only rendered when the page has an enabled contact section or social links), and a bottom bar with the copyright line + “Powered by Taikan” attribution.
  • Motion / reveal system — one small IntersectionObserver (threshold .15, one-shot unobserve) lives inline in modern.astro’s existing <body> <script> block, not a separate reveal.ts file. Elements opt in via .reveal (opacity + translateY + blur) or .reveal-fade (opacity-only, used where a layout shift would be disruptive — the pricing price figures never move). Progressive enhancement is load-bearing: the hidden initial CSS state is scoped under html.js (set synchronously as the very first line of the script), so no-JS/CSP-blocked/crawler contexts see full content immediately; @media (prefers-reduced-motion: reduce) independently strips the hidden state and all transitions regardless of html.js. The hero’s own entrance animation is a separate, pre-existing single-block CSS @keyframes heroReveal (not the shared .reveal system) — the plan’s “headline words stagger in” language does not describe what shipped; the hero fades/slides in as one block, not word-by-word.
  • Not built in this effort: the optional About-section “highlights” (owner-authored stat strings) and the corresponding dark bento stat-card moment (§5.2.8/§6.3 of the revamp plan) — there is no schema field for it and no AboutEditor UI. The .card-dark CSS primitive exists in modern.astro (dark surface, --on-dark text) but nothing currently renders it; it’s ready for that follow-up, not wired to any content today.
  • QA status: no screenshot-based visual QA pass has been run yet (RTL, cornerStyle/sectionDensity variants, reduced-motion, no-JS, Lighthouse). See qa-plan.md.

Pricing and purchase CTA (FIT-287)

The pricing section can show two things beyond the base plan card (name/description/price/interval/credits). The purchase CTA is server-gated (below); the intro/seat fields are not — they are emitted for every org.

Intro pricing + effective seats

Ungated since 2026-08-19. These used to sit behind a plan-intro-pricing PostHog flag; that flag was removed platform-wide as if permanently true. MinisitesService.getPlatformData (apps/api/src/organizations/minisites.service.ts) now unconditionally attaches to each plan in platformData.plans[]:

  • introPriceInCents, introDiscountPercent, introDurationCycles — copied straight from the plan row.
  • seatsLeft, soldOut — the effective (binding plan-vs-group) numbers, computed via computeSeatFields + loadGroupCaps (apps/api/src/plans/plan-capacity.util.ts), the same pure helpers PlansService uses for the dashboard plan list and enforcement. PlansService.seatFields/loadGroupCaps are now thin delegates over these exported functions so the two call sites can never disagree. Internal-only fields (seatsTaken, capScope, maxPurchases, planGroupId) never reach the public DTO — the minisite gets “n left / sold out”, not the org’s sales history.
  • Each field is still optional, because each is absent on its own terms: an uncapped plan carries no seat fields, and a plan with no intro terms carries nulls. Old published payloads that predate the fields parse unchanged.
  • A plan grouped with others under a capped plan_groups row shares the exact same seatsLeft/soldOut across every variant in the group, counting a member holding two variants as one seat.
  • Per-group showCapToMembers (FIT-289). The minisite is a member-facing audience (computeSeatFields(..., 'member')), so when a plan’s binding cap is a group whose owner turned showCapToMembers off, the DTO carries soldOut alone — no seatsLeft. Scarcity marketing is the owner’s call; “you cannot buy this” never is. A client must therefore never infer buyability from the presence of seatsLeft: Pricing.astro renders the spots-left pill only on seatsLeft != null && > 0, and the sold-out pill and waitlist CTA branch off soldOut alone. When the plan-level cap binds, or the group shows its counts, nothing changes.

Pricing.astro renders this as: a spots-left pill (amber) when seatsLeft > 0, a sold-out pill (destructive tone, card desaturated via filter: grayscale(.6); opacity: .75 but the pill itself stays full-contrast) when soldOut, and — when introDurationCycles > 0 and either intro field is set — the intro amount as the big price figure with a same-weight (not fine-print) note underneath: “First N payments, then ₪450/mo”. The intro-amount formula (introPriceInCents wins, else round(priceInCents * (100 - introDiscountPercent) / 100)) is duplicated in Pricing.astro with a comment pointing at the source of truth (libs/shared/src/lib/intro-pricing.ts) — the minisite app deliberately has no @taikan/shared workspace dependency. Plural/count copy (pricing.introFirstPayments.*, pricing.spotsLeft.*) goes through the new mtp() CLDR-plural helper in i18n.ts.

Purchase CTA (joinUrl)

A pricing section can be set to ctaMode: 'purchase' (default 'lead') in the editor — a new field on PricingSectionData (libs/shared/src/lib/minisite.ts), stored in content/published_content jsonb, no migration. On its own this does nothing: the public template never sees ctaMode directly, only the server-computed platformData.joinUrl.

MinisitesService.getPlatformData attaches joinUrl = ${FRONTEND_URL}/join/${org.joinToken} at the platformData level iff all of:

  1. the minisite-plan-purchase PostHog flag (FeatureFlags.MINISITE_PLAN_PURCHASE) is === true for the org (fail-closed; re-created key, previously archived with zero live code);
  2. org.joinEnabled === true and org.joinToken is non-null;
  3. the published pricing section (never the draft — read via publishedPricingCtaMode(), which walks every page’s sections looking for type === 'pricing', not just Home) exists, is enabled, and its ctaMode === 'purchase';
  4. the org has an active payment config (PaymentProviderConfigService.getActiveConfig(orgId)?.status === 'active') — external-payment-link-only orgs do not qualify in v1.

Any single condition failing omits joinUrl from the response entirely (never null) — the template needs zero flag logic, it just checks whether the field exists.

Pricing.astro’s CTA then branches three ways per plan:

ConditionCTA
joinUrl present, plan not sold out${joinUrl}?plan=${plan.id}&utm_source=minisite&utm_medium=pricing → “Join now”
joinUrl present, plan sold out#contact → “Join waitlist” (cosmetic — see Gaps in README.md)
joinUrl absent (flag off / mode not opted in / gate condition failed)#contact → today’s exact CTA, unchanged

The floating nav CTA (modern.astro) mirrors this: joinUrl (with ?utm_source=minisite&utm_medium=nav) when present, else the contact path.

Purchase journey: minisite pricing card → /join/:token?plan=<id> (public join links, FIT-251, already merged/unflagged) → Clerk invitation → sign-up → instant-active member → /shop?plan=<id> deep link → confirm dialog → hosted checkout. No new checkout surface, no new auth flow, no minisite JS talking to new endpoints — the CTA is a plain <a href> into the existing apps/web chain. Everything downstream (regulation-form gates, seat caps inside the subscription insert transaction, intro-price stamping, tier seat caps at join) is already enforced server-side; the minisite adds nothing new there.

Editor surface: PricingEditor (apps/web/.../section-editors/pricing-editor.tsx) gets the ctaMode radio (lead/purchase) plus, when purchase is selected, two informational (non-blocking) readiness hints reading GET /organizations/:id/join-link and GET /organizations/:orgId/payment-config — each links to the relevant settings page. The plan checklist also grows Intro/Cap: n chips per plan, driven purely by what the plan carries (ungated since the plan-intro-pricing flag was removed). The dashboard Overview tab surfaces a “Plan purchase: live / blocked - join link disabled / blocked - payments not active” status row whenever the published pricing section is in purchase mode — the owner’s only feedback loop for the server-side gate, since a silent joinUrl == null would otherwise look like a bug.

Staleness envelope

Seat counts and joinUrl inherit the existing resolve-endpoint caching, described fully under Caching below: the API’s 90s in-memory domainCache plus the edge’s s-maxage=120, stale-while-revalidate=300. Plan/seat edits and editor ctaMode changes do not proactively bust the API cache (only publish/unpublish/domain/visibility/events do); worst case a sold-out plan or a stale CTA mode shows for ~5-6 minutes. This is considered acceptable because the true state is enforced at purchase time inside the subscription-insert transaction (apps/api/src/subscriptions/subscriptions.service.ts), not by what the pricing card happened to render.

Lead popup

content.leadPopup drives a timed lead-capture modal rendered by apps/minisites/src/components/LeadPopup.astro (only when leadPopup.enabled is true).

{ "enabled": true, "title": "...", "body": "...", "ctaText": "...", "imageUrl": "...", "delaySeconds": 8 }
  • Fires after delaySeconds (clamped 0-120, default 8), or earlier on desktop exit intent (pointer leaving the top of the viewport).
  • Dismissal (close button, backdrop click, or a successful submit) is remembered per-org in localStorage (taikan_lead_popup_<orgId>), so it does not re-show.
  • On submit it posts { name?, phone, source: 'minisite' } to the existing POST /leads/organization/:orgId; phone is required.
  • The editor saves the config via PATCH /minisites/:orgId/lead-popup.

Localization

resolveLocale() (apps/minisites/src/lib/i18n.ts) normalizes the published-content locale, then the org locale, then falls back to en (not heraw.toLowerCase() matches he*/ru* prefixes and returns en for anything else, including a missing/malformed value). The layout renders dir="rtl" for he, ltr otherwise.

A companion helper, mtp(locale, baseKey, count), does CLDR-plural-aware lookups (Intl.PluralRules(locale).select(count) against <baseKey>.<one|two|few|many|other> keys, falling back to .other) — used by the pricing section for “spot(s) left” / “first N payments” copy (see below). mt() itself still falls through locale → en → raw key.

Draft vs publish

  • The dashboard editor writes to minisite_content.content on every save (autosave).
  • “Publish” copies contentpublished_content and sets is_published = true.
  • The Astro page only reads published_content; drafts are invisible to the public.
  • Republishing overwrites the snapshot atomically — no diff history kept.

Org website sync

  • Publishing writes the minisite URL into organizations.website — the link members see on their profile and every client renders as “the gym’s site”.
  • Unpublishing clears it again, so members never get a link to a subdomain that has just been pulled off Vercel.
  • Attaching or detaching a custom domain moves the stored URL with the site (<slug>.taikan.fitcustom.com).
  • The sync only ever writes a field the minisite owns: a blank value, the site’s own URL, or a stale *.taikan.fit host. An external site the owner typed in is never overwritten.
  • Decision logic is pure and unit-tested in apps/api/src/organizations/minisite-website-sync.ts; failures are logged and swallowed so a write hiccup can’t fail the publish.
  • The settings form still prefills a blank website field with the minisite URL, which now just agrees with what the server already wrote.

Custom domains

  • Owner sets custom_domain via the editor; the API persists it and instructs the owner to point a CNAME to cname.vercel-dns.com.
  • Vercel auto-issues a Let’s Encrypt cert once the CNAME resolves.
  • Until then, requests to the custom domain return Vercel’s default 404; Taikan does not currently surface a setup-in-progress page.

Caching

  • The API’s MinisitesService also holds an in-memory domainCache keyed by host, TTL 90s (CACHE_TTL_MS, apps/api/src/organizations/minisites.service.ts) — a resolve on a cache hit skips the DB entirely, including the FIT-287 flag evaluations and seat queries (they run once per host per 90s, not per request).
  • Astro emits Cache-Control: public, s-maxage=120, stale-while-revalidate=300 per response.
  • Vercel’s edge cache holds the response for 2 minutes; stale responses serve for an additional 5 minutes while a fresh fetch runs in the background.
  • Editor publish does not invalidate the cache — owners see updates within the SWR window. Manual invalidation requires a redeploy or a CDN purge. publishMinisite does call invalidateCacheForSite to bust the API’s domainCache, but that only runs on publish/unpublish/domain-attach/visibility/event changes — a bare plan edit (price, cap, intro terms) or seat count change does not bust it, so the pricing card’s seat state and joinUrl can lag up to the full 90s (API) + 120s/300s (edge) window behind reality. See Pricing and purchase CTA § Staleness envelope for why this is considered acceptable.

Outbound endpoints

EndpointPurpose
GET /minisites/resolve?host=<host>Returns data: { isPublished, publishedContent, theme, locale, platformData, organization, ... }. platformData includes events[], and (FIT-287, gated) plans[].introPriceInCents/introDiscountPercent/introDurationCycles/seatsLeft/soldOut and a top-level joinUrl. Public.
POST /leads/organization/:orgIdContact form and lead-popup submissions land via the leads service, tagged source: 'minisite'. Public.

Performance posture

  • Hero image preload (<link rel="preload" as="image">) lands in the head so LCP fires <2.5s on a cold cache.
  • Section components are static Astro; no client-side JS framework.
  • Runtime JS is limited to the floating WhatsApp button, the nav scroll/active-section observers, the scroll-triggered reveal observer (FIT-287, see Design rework), the contact/lead-popup form handlers, and the lead-popup timer/exit-intent listener. Payload stays small (inline <script>, no framework).

API resolve cost

getMinisiteByDomain is the Astro page’s blocking call, so its server time is the minisite’s TTFB floor. On a domainCache miss it runs ~13 queries in four stages: the host lookup, a seven-way Promise.all (org, locations, programs, plans, coaches, courses, events), the course-workout / upcoming-session follow-ups, and the seat/payment-config reads.

Measured 2026-09-03 (production spans, 63 requests/24h): p50 265ms server-side, of which the fan-out’s cold Postgres connects dominated — ~10 pool checkouts and ~4.8 new connections per request at 117ms each, because parallel queries each take their own connection and pg.Pool starts empty at boot. DB_POOL_WARM (see database.md) pre-opens the whole pool at boot, which took the same route to zero cold connects on a warm process.

Two details of this route’s cost:

  • A domainCache TTL of 90s cannot help at 63 requests/day — requests average ~23 minutes apart, so nearly every one is a miss. Raising the TTL trades directly against the live seatsLeft / soldOut figures in the payload, which is why it is short.
  • resolveSeatHoldOptions() is a synchronous, in-process calculation of the permanent 45-minute pending-checkout cutoff. Seat counting on this public path no longer performs a PostHog flag or payload read.