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

Minisites — QA Plan

Resolution

StepExpected
Hit https://<orgsubdomain>.taikan.fit/Astro fetches /minisites/resolve?host=<host> and renders publishedContent.
Hit a custom domain registered against an orgSame — resolves by custom_domain.
Hit a host that matches no rowAPI returns 404; Astro renders “Site not found”.
Org row exists but is_published = falseAstro renders 404 — drafts must not leak.
Org row exists, is_published = true, published_content = nullAstro renders 404.

Multi-page routing

StepExpected
Hit /Renders the home page’s sections.
Hit /courses with the Courses page enabledRenders the courses page. Similar for /workshops, /about, /contact.
Hit /courses with the Courses page disabledfindPage skips disabled pages; Astro returns 404 (“Page not found”).
Hit an unknown slug (e.g. /blog)Astro returns 404.
Hit /courses/<id>Per-course detail page still renders (separate route).
More than one page enabledTop nav renders page links (Home first, then enabled pages with showInNav).
Only the home page enabledNav falls back to in-page #anchor section links.

Pages (enable / disable / nav)

StepExpected
Owner enables a page in the Pages tab and publishesThe page’s slug resolves and the nav link appears after the SWR window.
Owner disables a pageIts slug 404s and the nav link disappears.
Owner edits a page’s nav labelNew label shows in the top nav.
Owner sets showInNav = false on an enabled pagePage still resolves by slug but is absent from the nav.
Section ops sent with ?page=<kind>The op targets that page’s sections; default page is home.

Legacy single-page back-compat

StepExpected
Row has content.sections only (no pages)getMinisitePages shims it into a single Home page; / renders those sections; unknown slugs 404.
Such a row is loaded in the editorensurePages() backfills the default pages (disabled) and any missing section types into Home, keeping content.sections mirrored.

Section rendering

StepExpected
Org has no programs, but classes section is enabledSection is filtered out of visibleSections and does not render.
Org has no upcoming sessionsschedule section is hidden.
Section’s isEnabled is falseSection is hidden regardless of data presence.
Two sections share the same orderStable order falls back to insertion sequence.

Pricing — intro terms & seat availability (FIT-287)

Maps to the FIT-287 acceptance criteria. No flag is involved any moreplan-intro-pricing was deleted (2026-08-19) and these fields are emitted for every org unconditionally. The only remaining owner control over the seat count is the per-group showCapToMembers toggle (FIT-289), covered at the end of the table.

StepExpected
Plan has no intro terms and no capCard renders exactly as before — no intro copy, no spots-left/sold-out pill; the plan’s intro fields are null and its seat keys are absent (not null).
Plan has introPriceInCents + introDurationCycles: 3Card shows the intro amount as the big price figure, with “First 3 payments, then ₪450/mo” (or the locale equivalent) at the same visual weight as the interval line, not fine print. Check he, en, and ru.
Plan has introDiscountPercent instead of a fixed intro priceDisplayed intro amount = round(priceInCents * (100 - introDiscountPercent) / 100), matching libs/shared/src/lib/intro-pricing.ts’s formula.
Plan maxPurchases capped, seats remainingAmber “N spot(s) left” pill renders (singular/dual/plural forms correct per locale via mtp()).
Plan fully sold out (seatsLeft: 0)Destructive “Sold out” pill renders at full contrast; the rest of the card is desaturated (grayscale(.6), opacity: .75) but still legible.
Two plans share one capped plan_groups row; one member holds both variantsBoth plan cards show the identical seatsLeft/sold-out state; the shared member counts as one seat, not two.
Group cap binds and the owner turns Show remaining spots to members off (group dialog → Purchase limit → group-show-cap-toggle)Public payload carries soldOut but no seatsLeft; the card shows no spots-left pill while the sold-out pill and the waitlist CTA still appear once the group fills. Dashboard/staff surfaces still show the full seat counts.
Same group, toggle back onThe spots-left pill returns after the resolve cache windows (~90s API + 120s/300s edge) elapse.
Plan is hidden (showOnMinisite=false) or soft-deletedExcluded from the pricing section regardless of intro/seat state (regression guard, unrelated to the flag).
Reference automated coverageapps/api/src/organizations/minisite-pricing-dto.int.spec.ts — DTO gating matrix, effective group seats, exclusion regressions.

Purchase CTA rollout journey (FIT-287)

Full rollout order per the revamp plan’s §7. Each step below assumes the prior one is done; run the whole journey once for a pilot org before widening.

StepExpected
Merge with minisite-plan-purchase off everywhereNo joinUrl on any minisite and ctaMode defaults to 'lead' on parse for old rows, so every CTA still points at #contact. Intro/seat fields DO render (ungated since 2026-08-19) — verify the pricing acceptance table above before rolling the purchase CTA.
Owner sets the pricing section’s “Plan button” to “Let visitors register and buy the plan” in the editor, then publishesOverview tab shows “Plan purchase” status: blocked - join link disabled or blocked - payments not active until the remaining conditions are met (join link enabled + an active payment config) — joinUrl still absent on the public site.
Flip minisite-plan-purchase for that org, with join link enabled and payment config activeOverview status flips to “live”; the public pricing cards’ CTA changes from #contact to <joinUrl>?plan=<id>&utm_source=minisite&utm_medium=pricing (“Join now” / locale equivalent), after the resolve cache windows (~90s API + 120s/300s edge) elapse. Sold-out plans keep a #contact CTA labeled “Join waitlist”. Nav CTA also switches to joinUrl.
Click “Join now” on a plan cardLands on /join/<token>?plan=<id> → registration → instant-active member → /shop?plan=<id> deep link → confirm dialog → hosted checkout (Cardcom test terminal in non-prod). No sign-in dead end, no App Link interception on iOS.
Click the CTA while already signed in as a member/join/<token> shows the join CTA; submitting is idempotent (alreadyMember: true) and still routes on to /shop?plan=<id>.
Turn the purchase flag back off (or set ctaMode back to lead) for the orgjoinUrl disappears from the resolve payload again (after the cache window); cards revert to the #contact CTA.
Any single §4.2 gate condition individually false (flag off / join link disabled / token null / ctaMode !== 'purchase' / pricing section disabled or missing / payment config missing or not active)joinUrl absent — covered by the joinUrl condition matrix describe block in minisite-pricing-dto.int.spec.ts.

Design rework visual QA (FIT-287)

No screenshot-based pass has been run yet — this checklist is what’s outstanding before sign-off, not a record of what’s been verified. Seed a dev org from apps/minisites/scripts/seed-erezmotion-prod.sql (the design rework is unflagged and reskins Erez’s live production site on deploy).

CheckExpected
he (RTL) at 390px and 1440pxFloating pill nav, hero, bento grids, footer all mirror correctly via logical properties; no double-flipped icons/chevrons.
en and ru at the same breakpointsLTR layouts unaffected; plural/count copy renders correctly (ru needs its few/many forms exercised, e.g. 2-4 spots left vs 5+).
Each cornerStyle value (sharp / soft / round)Card/button/pill radii match the documented scale in behavior.md; buttonStyle and cornerStyle don’t visually fight on the same element.
Each sectionDensity value (compact / normal / spacious)Vertical section rhythm changes; no overlapping content at the sticky Schedule day-header.
A non-copper colorPrimaryAccent-derived tokens (--accent-soft, hover states, badges) still contrast correctly against --paper/--surface.
Single-page site (anchor nav) vs multi-page site (page-link nav)Nav CTA and footer nav column both resolve correctly in each mode; footer nav column has no heading (by design — it reuses the primary nav link set).
prefers-reduced-motion: reduceNo .reveal/.reveal-fade hidden initial state, no transitions, no hover-lift transforms; content is immediately fully visible.
No-JS (disable JavaScript)Every section renders fully visible (the html.js gate never fires, so the CSS-hidden .reveal state never applies); FAQ accordion (<details>) still expands/collapses natively.
Lighthouse on the seeded home pagePerformance ≥ 90 mobile, CLS < 0.05 (pricing price figures use .reveal-fade, opacity-only, specifically to avoid CLS in the money section), a11y ≥ 95.
Prod CSP headers (script-src 'self'; connect-src 'self')Still pass — no inline event handlers, no external script/connect origins beyond the existing Google Fonts <link> tags.

Theme

StepExpected
Owner edits primary color in editor and publishesNew color appears on next request after the 120s SWR window.
Owner sets a fontFamily not in the enumFalls back to inter.
Legacy row has primaryColor = #000000 (or empty/black)Layout normalizes it to the copper default #b86a35; a real custom color is honored as-is.
Owner sets cornerStyle / sectionDensity / headingFontFamilyCard rounding, section rhythm, and heading font update via the layout CSS variables.
Owner uploads a favicon<link rel="icon"> points at the uploaded URL.

Localization

StepExpected
Org locale = hePage renders RTL with Hebrew strings.
Org locale = enLTR English.
Org locale = ruLTR Russian.
Locale is malformedFalls back to he.

Live data freshness

StepExpected
Add a class session in the dashboardNew session appears on the schedule within 2 min (cache TTL).
Pause a planPlan disappears from pricing within 2 min.
Force-refresh via Vercel cache purgeUpdates visible immediately.

Performance

CheckThreshold
LCP on mobile (Lighthouse)< 2.5s.
Total JS bytes< 30KB gzipped (WhatsApp button + scroll observers only).
Hero image preload tag presentYes when hero has backgroundImage.

SEO

StepExpected
<title> and <meta description> come from seoTitle / seoDescriptionWhen set, override default.
robots.txt served at /robots.txtReturns a 200 with the org’s robots directives.
Cache-Control headerpublic, s-maxage=120, stale-while-revalidate=300.

Contact / lead capture

StepExpected
Submit the contact formLead lands in the org’s leads pipeline tagged as source: 'minisite'.
Submit with spam payload (honeypot triggered)Server rejects; no lead created.

Events / workshops

StepExpected
Create an event in the Events tab (POST /minisites/:orgId/events)Row created; appears in the editor list.
Edit / delete an event (PATCH / DELETE .../events/:eventId)Update persists / soft-delete via deleted_at.
Publish an upcoming event and enable the events sectionEvent card renders from platformData.events[] after the SWR window.
Event is unpublished or in the pastExcluded from platformData.events[]; section hides if no events remain.
Click an event CTARoutes to the Contact page (/contact) when enabled, otherwise the in-page #contact anchor. No checkout (events are lead-capture).
price_label set (e.g. “₪450”)Shown as a display-only label; no payment is initiated.

Lead popup

StepExpected
leadPopup.enabled = true, wait delaySecondsPopup fires after the delay.
Desktop pointer leaves the top of the viewport before the delayPopup fires on exit intent.
Dismiss (close, backdrop, or successful submit) then reloadPopup does not re-show (remembered in localStorage taikan_lead_popup_<orgId>).
Submit with name + phonePosts { name, phone, source: 'minisite' } to /leads/organization/:orgId; success state shown.
Submit with empty phoneRejected client-side; no request sent.
leadPopup.enabled = falseLeadPopup.astro is not rendered.

Social section

StepExpected
Set channel links + curated post thumbnailsChannel icons link out; thumbnails render in a grid.
Thumbnail has a linkThumbnail links to the post; without it, renders as a static image.
Section isEnabled = falseSection is hidden.

Custom domain provisioning

StepExpected
Owner adds custom_domain and points CNAME to cname.vercel-dns.comVercel auto-issues cert; requests resolve.
Owner adds domain but never updates CNAMERequests return generic Vercel 404 (gap — see README).

Negative tests

  • Hit /minisites/resolve?host= with an empty host → API returns 400.
  • Hit the resolve endpoint with a host that includes a port → API strips the port before lookup.
  • Render with content.sections empty → only the hero (always-on) renders.