Minisites — Data Model
minisite_content
Defined in libs/db/src/lib/schema/minisites.ts. One row per organization (unique on organization_id).
| Column | Type | Purpose |
|---|---|---|
id | uuid PK | Row identity. |
organization_id | uuid FK → organizations.id, unique | One minisite per org. |
content | jsonb | Working draft: pages[], theme, leadPopup, founderStory, plus legacy sections mirrored from the home page. Edited by the dashboard. |
published_content | jsonb nullable | Snapshot served to the public. Null until first publish. |
theme | jsonb | Theme tokens: primaryColor, secondaryColor, fontFamily, headingFontFamily?, buttonStyle, cornerStyle, sectionDensity. The DB column default still carries the pre-revamp values; the application default (DEFAULT_THEME in @taikan/shared) and the layout are warm copper, and modern.astro normalizes legacy primaries (#000000, etc.) to the copper default. |
custom_domain | varchar(255) unique | Apex/subdomain owned by the gym (e.g. fit.gymname.com). |
subdomain | varchar(255) unique | *.taikan.fit subdomain assigned by Taikan. |
seo_title | varchar(255) | <title> override. |
seo_description | text | Meta description. |
favicon_url | text | Custom favicon. |
is_published | boolean (default false) | Public visibility gate. |
created_at / updated_at | timestamp tz | Audit columns. |
Relations
minisite_content↔organizationsone-to-one. Deleting an org cascades the minisite row via the FK.
minisite_events
Defined in libs/db/src/lib/schema/minisites.ts; created by migration libs/db/drizzle/0066_lying_siren.sql. Dated, promotable events/workshops for an org. Many rows per org. Events are NOT purchasable; registration is lead-capture only (the CTA points at the contact page/section), so there is no payment/entitlement coupling.
| Column | Type | Purpose |
|---|---|---|
id | uuid PK | Row identity. |
organization_id | uuid FK → organizations.id | Owning org. |
title | varchar(255) | Event title. |
description | text nullable | Event blurb. |
image_url | text nullable | Promo image. |
starts_at | timestamp tz | Start time. |
ends_at | timestamp tz nullable | Optional end time. |
location_text | varchar(255) nullable | Free-text location. |
price_label | varchar(80) nullable | Display-only label (e.g. “₪450” or “Free”), not a real price. |
cta_label | varchar(80) nullable | Optional override for the lead CTA button. |
sort_order | integer (default 0) | Ordering within the same start time. |
is_published | boolean (default false) | Whether it surfaces on the public site. |
created_at / updated_at | timestamp tz | Audit columns. |
deleted_at | timestamp tz nullable | Soft-delete marker. |
- Index
minisite_events_org_idxon(organization_id, starts_at). - The resolve endpoint surfaces upcoming published rows in
platformData.events[]; the editor manages them via the events CRUD endpoints.
Live data (not persisted in this module)
The platformData payload composed by the resolve endpoint pulls from existing tables:
| Section | Source |
|---|---|
programs | programs |
upcomingSessions | class_sessions joined to class_types |
plans | plans (+ derived, gated, see below) |
courses | courses |
coaches | memberships filtered by role = 'coach' joined to users |
events | minisite_events (upcoming + published) |
organization | organizations |
events is the one platform-data source authored inside the minisites module (the minisite_events table below); the rest are external tables. No copy of these lives in minisite_content.content, so the minisite always sees fresh data.
Pricing DTO extensions (FIT-287)
Each element of platformData.plans[] carries the pre-existing 8 fields (id, name, description, type, priceInCents, currency, interval, classCredits) plus five optional fields. These were once gated on a plan-intro-pricing PostHog flag; that flag was removed (2026-08-19) and the fields are now emitted for every org. They stay optional because each is absent on its own terms — absent from the JSON entirely, not merely null:
| Field | Type | Source |
|---|---|---|
introPriceInCents | number | null | plans.intro_price_in_cents, copied as-is. |
introDiscountPercent | number | null | plans.intro_discount_percent, copied as-is. |
introDurationCycles | number | null | plans.intro_duration_cycles, copied as-is. |
seatsLeft | number | null | Derived, not a column — the effective (binding plan-vs-group) seats-remaining, via computeSeatFields/loadGroupCaps (apps/api/src/plans/plan-capacity.util.ts), shared with PlansService’s enforcement path (FIT-289). Absent when nothing is capped, and when the binding cap is a group with show_cap_to_members = false (see below). |
soldOut | boolean | Derived: seatsLeft === 0. Emitted whenever anything is capped, including for a hidden-count group. |
Internal-only derived fields (seatsTaken, capScope, maxPurchases, planGroupId) never reach this public DTO.
Per-group show_cap_to_members (FIT-289). plan_groups.show_cap_to_members (boolean NOT NULL DEFAULT true) is an owner toggle on the group. The minisite resolves seats for a member audience (computeSeatFields(..., 'member')), so when a plan’s binding cap is a group with the toggle off, the DTO emits soldOut and omits seatsLeft — the two are emitted independently for exactly this reason. Consumers must key buyability off soldOut, never off whether seatsLeft is present. Staff-facing payloads (PlansService, dashboard) are unaffected and always carry the full seat set.
platformData also gains a top-level optional joinUrl: string | null (FIT-287) — the org’s public join link (${FRONTEND_URL}/join/${org.joinToken}), present only when the full purchase-CTA gate passes: minisite-plan-purchase flag on, org.joinEnabled with a token, published pricing section isEnabled with ctaMode: 'purchase', and an active payment config. See behavior.md § Pricing and purchase CTA for the full rule and rendering.
Both extensions are additive to the shared type MinisitePlatformData (libs/shared/src/lib/minisite.ts) — old published payloads and flag-off orgs parse unchanged.
Indexes & uniqueness
custom_domainandsubdomainare independently unique to support either resolution path.- Lookup happens by host header at request time — both columns are read; performance relies on the unique indexes.
Content shape (informal)
Authoritative Zod schemas live in libs/shared/src/lib/minisite.ts (MinisiteContent, MinisitePage, MinisiteSection, MinisiteTheme).
type Section = {
type: 'hero' | 'about' | 'classes' | 'schedule' | 'pricing'
| 'courses' | 'trainers' | 'contact' | 'gallery'
| 'testimonials' | 'faq' | 'social' | 'events';
order: number;
isEnabled: boolean;
data: Record<string, unknown>; // section-specific payload
};
type Page = {
kind: 'home' | 'courses' | 'workshops' | 'about' | 'contact';
slug: string; // '' for home; mirrors PAGE_SLUGS[kind]
navLabel: string;
isEnabled: boolean; // page renders + can appear in nav
showInNav: boolean; // surface in top nav (home often hidden)
order: number;
sections: Section[];
};
type MinisiteContent = {
title?: string;
locale?: 'he' | 'en' | 'ru';
theme?: {
primaryColor?: string; // default copper #b86a35
secondaryColor?: string; // default cream #f4efe7
fontFamily?: string;
headingFontFamily?: string; // optional separate display font
buttonStyle?: 'rounded' | 'pill' | 'square';
cornerStyle?: 'sharp' | 'soft' | 'round';
sectionDensity?: 'compact' | 'normal' | 'spacious';
};
founderStory?: {
enabled: boolean; title: string; body: string;
credentials?: string; ctaHook?: string;
};
leadPopup?: {
enabled: boolean; title: string; body: string;
ctaText: string; imageUrl?: string; delaySeconds: number; // 0-120, default 8
};
pages: Page[]; // multi-page source of truth (FIT-209)
sections?: Section[]; // @deprecated legacy single-page list, mirrored to the home page
};Back-compat shim
pages[]is the source of truth. Legacy rows carry onlysections(nopages).getMinisitePages(content)(in@taikan/shared, mirrored client-side bygetPagesinapps/minisites/src/lib/pages.ts) returnscontent.pageswhen present, otherwise wrapscontent.sectionsinto a singlehomepage.- The API service
ensurePages()upgrades rows in place: shims legacy sections into Home, backfills missing default pages (disabled), and backfills missing section types into Home. It keepscontent.sectionsmirrored to the Home page’s sections.
New section data shapes
social(SocialSectionData):{ title, description, instagramHandle?, instagramUrl?, tiktokUrl?, facebookUrl?, youtubeUrl?, posts: { imageUrl, link? }[] }. Manual.events(EventsSectionData):{ title, description, showEventIds: string[] }. Config-only; the renderer pulls rows fromplatformData.events[](emptyshowEventIdsshows all upcoming published events).
pricing section data (PricingSectionData)
{ title, featuredPlanIds: string[], highlightPlanId?, valuePillar?, stackReductionNote?, ctaMode: 'lead' | 'purchase' }.
ctaMode (FIT-287, default 'lead') is the owner’s opt-in toggle for the purchase CTA — set via the PricingEditor’s radio control, published like any other section field. On its own it changes nothing: the Astro renderer never reads ctaMode directly, only the API-computed platformData.joinUrl, which additionally requires the minisite-plan-purchase flag and an active payment config (see Pricing DTO extensions above). No migration — the field lives in the existing content/published_content jsonb and defaults old rows to 'lead' on parse.
The Astro page treats data as opaque per-section JSON; schema is enforced in the dashboard editor and @taikan/shared.