Minisites — QA Plan
Resolution
| Step | Expected |
|---|---|
Hit https://<orgsubdomain>.taikan.fit/ | Astro fetches /minisites/resolve?host=<host> and renders publishedContent. |
| Hit a custom domain registered against an org | Same — resolves by custom_domain. |
| Hit a host that matches no row | API returns 404; Astro renders “Site not found”. |
Org row exists but is_published = false | Astro renders 404 — drafts must not leak. |
Org row exists, is_published = true, published_content = null | Astro renders 404. |
Multi-page routing
| Step | Expected |
|---|---|
Hit / | Renders the home page’s sections. |
Hit /courses with the Courses page enabled | Renders the courses page. Similar for /workshops, /about, /contact. |
Hit /courses with the Courses page disabled | findPage 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 enabled | Top nav renders page links (Home first, then enabled pages with showInNav). |
| Only the home page enabled | Nav falls back to in-page #anchor section links. |
Pages (enable / disable / nav)
| Step | Expected |
|---|---|
| Owner enables a page in the Pages tab and publishes | The page’s slug resolves and the nav link appears after the SWR window. |
| Owner disables a page | Its slug 404s and the nav link disappears. |
| Owner edits a page’s nav label | New label shows in the top nav. |
Owner sets showInNav = false on an enabled page | Page 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
| Step | Expected |
|---|---|
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 editor | ensurePages() backfills the default pages (disabled) and any missing section types into Home, keeping content.sections mirrored. |
Section rendering
| Step | Expected |
|---|---|
Org has no programs, but classes section is enabled | Section is filtered out of visibleSections and does not render. |
| Org has no upcoming sessions | schedule section is hidden. |
Section’s isEnabled is false | Section is hidden regardless of data presence. |
Two sections share the same order | Stable 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 more — plan-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.
| Step | Expected |
|---|---|
| Plan has no intro terms and no cap | Card 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: 3 | Card 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 price | Displayed intro amount = round(priceInCents * (100 - introDiscountPercent) / 100), matching libs/shared/src/lib/intro-pricing.ts’s formula. |
Plan maxPurchases capped, seats remaining | Amber “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 variants | Both 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 on | The spots-left pill returns after the resolve cache windows (~90s API + 120s/300s edge) elapse. |
Plan is hidden (showOnMinisite=false) or soft-deleted | Excluded from the pricing section regardless of intro/seat state (regression guard, unrelated to the flag). |
| Reference automated coverage | apps/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.
| Step | Expected |
|---|---|
Merge with minisite-plan-purchase off everywhere | No 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 publishes | Overview 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 active | Overview 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 card | Lands 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 org | joinUrl 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).
| Check | Expected |
|---|---|
he (RTL) at 390px and 1440px | Floating pill nav, hero, bento grids, footer all mirror correctly via logical properties; no double-flipped icons/chevrons. |
en and ru at the same breakpoints | LTR 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 colorPrimary | Accent-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: reduce | No .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 page | Performance ≥ 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
| Step | Expected |
|---|---|
| Owner edits primary color in editor and publishes | New color appears on next request after the 120s SWR window. |
Owner sets a fontFamily not in the enum | Falls 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 / headingFontFamily | Card 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
| Step | Expected |
|---|---|
Org locale = he | Page renders RTL with Hebrew strings. |
Org locale = en | LTR English. |
Org locale = ru | LTR Russian. |
| Locale is malformed | Falls back to he. |
Live data freshness
| Step | Expected |
|---|---|
| Add a class session in the dashboard | New session appears on the schedule within 2 min (cache TTL). |
| Pause a plan | Plan disappears from pricing within 2 min. |
| Force-refresh via Vercel cache purge | Updates visible immediately. |
Performance
| Check | Threshold |
|---|---|
| LCP on mobile (Lighthouse) | < 2.5s. |
| Total JS bytes | < 30KB gzipped (WhatsApp button + scroll observers only). |
| Hero image preload tag present | Yes when hero has backgroundImage. |
SEO
| Step | Expected |
|---|---|
<title> and <meta description> come from seoTitle / seoDescription | When set, override default. |
robots.txt served at /robots.txt | Returns a 200 with the org’s robots directives. |
Cache-Control header | public, s-maxage=120, stale-while-revalidate=300. |
Contact / lead capture
| Step | Expected |
|---|---|
| Submit the contact form | Lead lands in the org’s leads pipeline tagged as source: 'minisite'. |
| Submit with spam payload (honeypot triggered) | Server rejects; no lead created. |
Events / workshops
| Step | Expected |
|---|---|
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 section | Event card renders from platformData.events[] after the SWR window. |
| Event is unpublished or in the past | Excluded from platformData.events[]; section hides if no events remain. |
| Click an event CTA | Routes 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
| Step | Expected |
|---|---|
leadPopup.enabled = true, wait delaySeconds | Popup fires after the delay. |
| Desktop pointer leaves the top of the viewport before the delay | Popup fires on exit intent. |
| Dismiss (close, backdrop, or successful submit) then reload | Popup does not re-show (remembered in localStorage taikan_lead_popup_<orgId>). |
| Submit with name + phone | Posts { name, phone, source: 'minisite' } to /leads/organization/:orgId; success state shown. |
| Submit with empty phone | Rejected client-side; no request sent. |
leadPopup.enabled = false | LeadPopup.astro is not rendered. |
Social section
| Step | Expected |
|---|---|
| Set channel links + curated post thumbnails | Channel icons link out; thumbnails render in a grid. |
Thumbnail has a link | Thumbnail links to the post; without it, renders as a static image. |
Section isEnabled = false | Section is hidden. |
Custom domain provisioning
| Step | Expected |
|---|---|
Owner adds custom_domain and points CNAME to cname.vercel-dns.com | Vercel auto-issues cert; requests resolve. |
| Owner adds domain but never updates CNAME | Requests 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.sectionsempty → only the hero (always-on) renders.