VITE_QUIZ_VARIANT="paywall_v2_2" as a build-time constant. Variant v2_2's tier list contains no annual plan, and the tier function takes only the variant — never the path — so 100% of traffic, on every path, has been unable to buy annual since 20 June — last annual sale 19 Jun, then 21 days at exactly zero (20 Jun – 9 Jul). Annual is 48.9% of new-sale cash. This brief does two things: (1) adds an annual rung so the paywall can sell the plan that pays, and (2) replaces the hardcoded constant with sticky randomised assignment so we can run a real test instead of shipping blind. Both are small. Do them together.
Ship it as the HYBRID, not a naive swap:
🔴 Still absolute: never put $97 behind a trial. At the measured 36.9% trial conversion that models at −9% vs today. $97 is safe only when charged today. If you find yourself building "$97, $0 today", stop and flag it.
Kill-switch: rolling 14-day revenue per paywall view below $6.04 ⇒ revert to the live tier list and tell Aga.
2. The date is not a proven cause. Stripe shows the last annual sale on 19 June 2026 and then 21 days at exactly zero (20 Jun – 9 Jul) — no other dry gap of even 7 days since February. That part is solid. But the new quiz funnel went live around May, so the June break is a later change, not the launch. Please pull the actual paywall-variant switch date from deploy history — it is the one fact that turns this from correlation into cause, and you are the only person who can get it.
| In scope | Out of scope |
|---|---|
New paywall variant v2_5 (Arm A) — additive to v2_2 | Redesigning the quiz body or the landing screen |
Fallback variant v2_6 — $157 behind the trial, built not shipped | Any change to the checkout/register page design |
| Sticky randomised variant assignment + persistence | Changing renewal prices on the existing book |
Emitting quiz_variant to GA4 (event param + user property) | The 4 dead traffic sources (Aga owns, separate task) |
Forwarding UTMs + variant into Stripe subscription_data.metadata | Any native-app pricing (RevenueCat rail is untouched) |
v2_5 (Arm A). This is the cash.v2_1 is not a safe drop-in — it deletes quarterly and trial-gates every tier.
v2_5 — THE SHIP| Tier | Price | Mechanic | addToCartValue | Selected |
|---|---|---|---|---|
| 4-Week | $12.48 | charged today — unchanged | 12.48 | no |
| 12-Week | $24.98 | charged today — unchanged | 24.98 | no |
| 12-Month | $97 | CHARGED TODAY · no trial | 97 | YES — pre-selected on render |
This is why the ramps stay at their current prices. At $157 the ladder inverted (12-week worked out cheaper per day than annual) and would have forced a reprice of the ramps. At $97 it does not. Change nothing about the 4-week and 12-week cards.
tiers.find(t => !!t.checkoutUrl) — but verify it selects the 12-Month rung, not the first direct-charge tier. If ordering makes that fragile, select explicitly by tier id.addToCartValue must carry the real price. The live trial tier sends value 0, and Meta optimises on add_to_cart — so we are actively training the ad platform on a zero-value event. Send the plan's actual price.v2_6 as the FALLBACK build it, do not ship itThe fallback is the earlier CMO/CEO recommendation: annual $157, pre-selected, behind the 7-day trial, ramps unchanged. Build it behind the flag so a revert is a config change, not a sprint.
| Tier | Price | Mechanic | Notes |
|---|---|---|---|
| 4-Week | $12.48 | charged today | unchanged |
| 12-Week | $24.98 | charged today | unchanged |
| 12-Month | $157 | $0 today · 7-day trial | pre-selected; keeps the published $157 anchor |
When the fallback gets used: the kill-switch fires (14-day RPV < $6.04), or Aga rules again after the first $97 cohorts read out.
Today: VITE_QUIZ_VARIANT is a build constant; zt() reads it and Ice only validates it. There is no randomisation, so every variant is 100%/0% and no test can run.
// Required behaviour — assign ONCE, on first quiz load, then never change. // Sticky across sessions and devices-per-browser. Server-side is fine too. const ARMS = ['paywall_v2_5', 'paywall_v2_6']; // 50/50 const KEY = 'tma_quiz_arm_v1'; function getArm() { // 1. explicit override always wins — needed for QA and for Aga to preview const forced = new URLSearchParams(location.search).get('arm'); if (forced && ARMS.includes(forced)) return forced; // 2. sticky assignment let arm = null; try { arm = localStorage.getItem(KEY); } catch (e) {} if (!arm) arm = readCookie(KEY); if (arm && ARMS.includes(arm)) return arm; // 3. first touch — assign and persist to BOTH stores arm = ARMS[Math.floor(Math.random() * ARMS.length)]; try { localStorage.setItem(KEY, arm); } catch (e) {} writeCookie(KEY, arm, 180); // 180 days, root domain return arm; }
?arm= override must not persist — QA previews must not bucket a real visitor.Bucketing without instrumentation produces a test nobody can read. These three ship with the variants, not after.
quiz_variant as event param AND user propertygtag('set', 'user_properties', { quiz_variant: arm });
gtag('event', 'quiz_started', { quiz_variant: arm, screen_id: 'S01' });
subscription_data.metadataThis fixes the test and the dark attribution in one change. The quiz already appends utm_source · utm_medium · utm_campaign · utm_content · quiz_variant · screen_id + email to the register URL. The break is that the register page never forwards them into Stripe: subscription.metadata and invoice.metadata are empty on all 154 sales in the last 90 days.
stripe.checkout.sessions.create({
// …existing config…
subscription_data: {
metadata: {
quiz_variant: arm,
utm_source: params.utm_source || '',
utm_medium: params.utm_medium || '',
utm_campaign: params.utm_campaign || '',
utm_content: params.utm_content || '',
quiz_session: params.session_id || ''
}
}
});
Stripe propagates this to invoice.subscription_details.metadata — verified live, the field exists and is currently {}. Once set, every future sale is attributable to arm and source without any new pipeline.
if (i !== "control" && !P?.checkoutUrl) return; that exits without firing any event. Its victims are invisible by construction — they are not in the 356 counted buy-click abandoners. Emit an event before returning so we can size it. Until this logs, "the dead button costs $X" is unmeasurable, not small.
| # | Defect | Fix |
|---|---|---|
| 1 | GET app.themovementathlete.com/api/quiz/config returns 404 — quiz runs on fallback config | Restore the endpoint. Plausible (unproven) root cause of the dead downsell button. |
| 2 | The downsell button ("show me another option") has an onclick but does nothing across 6 screens | Wire it, or remove it. A dead price-objection escape hatch is worse than none. |
| 3 | A "Report Bug" widget ships on the production paywall, overlapping the primary CTA on mobile | Remove from production build. |
| 4 | Copy says "96 progression levels across 9 foundations" | Change to "100+ progression levels across the 9 Fundamentals" — source of truth is 123; customer-facing copy uses the drift-proof "100+". |
| 5 | Funnelytics tracker returns 403 | Fix or remove — a broken tracker on every page load is latency for nothing. |
| 6 | 🔴 STRIPE — the billing period on both ramps is misstated. Live Stripe: monthly3 ($24.97) is interval: month, interval_count: 1 → bills every month. quarterly2 ($49.97) is interval_count: 3 → bills every 3 months. No TMA price in the 82 active prices uses a week interval — there is no 4-week or 12-week product. The paywall says "4 Weeks / then $24.97 per 4 wks" (implies 13 charges a year) and "12 Weeks / then $49.97 per 12 wks". | Relabel to the real cadence: 4 Weeks → 1 Month, 12 Weeks → 3 Months, "/ 4 wks" → "every month", "/ 12 wks" → "every 3 months". No arithmetic changes — $287 already = 12 monthly cycles and $175 already = 4 quarterly cycles, both computed on the true cadence. Labels only. CMA209 §5.32 requires the price stated per actual billing period. |
| 7 | 🔴 STRIPE — the hero's mechanic does not exist. The paywall promises "$97 today … renews $157". There is no $60-off / 38.2%-off coupon in the Stripe account (coupon set is percent-off 30/50/70/99 plus tma_v3_50off_once), and every annual sub created since 1 Jun 2026 used the $157 price with no coupon at all. | Mint a duration: once mechanism that charges $97 on the first annual term and $157 thereafter, or the "renews $157" line is unbuildable as written. Do not implement $97 as a trial — models −9% at the measured 36.9% trial conversion. $97 is safe only when charged today. |
| 8 | The ramps' 50%-off is real and is already minted: coupon tma_v3_50off_once — "TMA 50% first cycle", percent_off: 50, duration: once, valid, 9 redemptions. | No action — recorded so nobody re-mints it. This is what backs the new on-card line "Half price for your first 12 weeks / 4 weeks". |
The full 4-model comparison page still exists if you want to see the arms that were not chosen: docs/company/PRICING/paywall-4model-v2-2026-07-13/site/index.html (live:
tma-revenue-plan-2026.netlify.app/pricing/…/site/). It opens on the m7 SHIP arm. Reference only — build from the contract.
🔴 Both are generated from the same source, so they can never disagree: the contract is emitted from the comparison page by build_nic_spec.py. If one changes, the other is regenerated and redeployed in the same pass. Neither is hand-edited.
Either way the design is complete. What it cannot do by itself:
| # | What is stubbed | What you must wire |
|---|---|---|
| 6.1 | Every CTA scrolls instead of paying. exitGo(), stBtn and cta2Btn all call scrollIntoView. The line is marked // PRODUCTION: in the source. | Hand off to real checkout for the SELECTED tier. The selected tier is on .pkhero.sel / .pkramp.sel via data-m7 (12mo / 12wk / 4wk). 🔴 The sticky CTA must carry the selected tier through — today's live build enrols every converting trial at $24.97 regardless of what was tapped. |
| 6.2 | tma_ckt_out is never set. The exit modal's only mobile-viable trigger reads this sessionStorage flag on return from checkout. | Set sessionStorage.setItem('tma_ckt_out','1') at the moment the CTA hands off to Stripe, and set window.__tmaPurchased = true on success. Without both, the exit modal cannot fire on mobile at all — mouseout needs a cursor. |
| 6.3 | Exit-modal analytics push to window.dataLayer via a local track() shim. | Point track() at the real analytics layer. Events already emitted: paywall_exit_modal_shown (with reason = back / checkout_return / pointer), paywall_exit_modal_cta, paywall_exit_modal_dismiss. Carry quiz_variant, session_id, path, device and the UTM set on each. |
| 6.4 | The back-button trap was infinite in the previous build — popstate re-pushed unconditionally, so back never left the page. Fixed in the mockup: it intercepts once then releases. | Carry the fixed version across. Do not restore the re-push. Named dark pattern; UK DMCC direct enforcement live since Apr 2025 with fines to 10% of global turnover. |
| 6.5 | 🔄 CHANGED 29 Jul (Aga) — the countdown is BACK on m7, and it must be REAL. The strip reads "⚡ EXCLUSIVE QUIZ PRICE · closes in 47:59:59 · then $157/yr". The mockup already runs a genuine per-visitor 48h deadline (localStorage, 7-day cooldown, never silent-resets) and expires to a real state: body.exp swaps the strip and shows .expmsg.m7only. | Your half: the deadline must be enforced server-side. At T+48h from quiz completion the $97 coupon must actually die and the page must serve $157. The client timer and the server expiry must share one deadline (persist it with the quiz session). A timer whose expiry changes nothing is a misleading-urgency claim (CMA, Emma Sleep; DMCC fines to 10% of global turnover) — if the server half can't ship in the same deploy, ship WITHOUT the countdown and tell Aga. |
| 6.6 | Hero card had no unselected state — tapping a ramp left two teal cards with the annual still reading "Selected". Fixed via .pkhero.sel. | Carry across. On a payment surface a shopper who cannot tell what they are about to be charged does not tap. |
| 6.7 | addToCartValue: 0 on the live build. | Send the selected tier's real price. A zero-value add_to_cart poisons every downstream optimisation signal. |
| 6.8 | NEW 29 Jul — the exit modal is now TWO steps. Step 1 is the guarantee reframe (unchanged). "Not now" opens step 2: the free-app route — "Try it on us — 3 workouts, free." with real App Store / Google Play links. | Carry both steps. exitStep2() / exitBack() / exitStore(store) and exitReset() are in the source. The modal must always open on step 1 (exitReset() runs on every open). New events: paywall_exit_free_shown, paywall_exit_free_back, paywall_exit_free_store_click{store}. |
| 6.9 | Order summary above the card fields (#m7ord, driven by the M7_ORD map). Plan name · charged today · renewal terms. | Keep it, and keep it in lockstep with the selected tier and the sticky bar. Before this existed a buyer could tap 4 Weeks, scroll three screens of proof, and arrive at the card fields with nothing on screen stating what they were about to be charged. Every real checkout restates the line item above the card. |
| 6.10 | Tier selection now fires analytics — selM7() emits paywall_plan_selected{plan, from, seq, ms_since_view}. | Wire it through. Previously every tier tap was invisible, which is why 604 buy-clicks → 154 paid has never been explainable. 12mo→4wk→12mo→buy is a price-anxious buyer you converted; 12mo→4wk→exit is a clean price objection. |
| 6.11 | 🔴 The status-bar demo hook. Tapping the phone status bar toggles the exit modal. | Now gated behind ?demo=1 so it cannot reach production. Preview at …/site/?demo=1. Do not ship it ungated — you build this page verbatim, and an ungated version is a live defect. |
The old bundle's button reads "I don't want to pay anything — show me another option" and leads to the free app ("No credit card required. Ever."). That is a zero-price option, not a cheaper rung — and its second step was never built (alert("Prototype: would send free-workout email.")).
Two CRO seats ruled independently: it does not go beside a paid tier. Shampanier, Mazar & Ariely (2007), Marketing Science 26(6): holding the price difference constant, moving the cheap option to $0 flipped shares from ~40/40 to ~90/10. Next to a pre-selected $97 that is a share-flip risk, not a marginal one — and this page exists to move mix back to annual.
The rule for the build: the free route appears only after the paid offer has been declined, on step 2 of the exit modal. No store link, and no free-tier mention, anywhere in the plan row. If a future change puts one there, this ruling has to be re-argued first.
We have already shipped this exact kind of control once, wired 8 events to it, and can read none of them.
2026-v2-2 fires 13 event names, including 8 exit_popup_* (p1_shown / p1_cta_clicked / p1_dismissed / p2_shown / p2_cta_clicked / p2_dismissed / app_shown / app_store_clicked).2026-v2-3 — the newer bundle — fires zero of them. The instrumentation was dropped.exit_popup_* event. Nor email_captured, email_gate_viewed, or purchase. Only the two GA4-recommended names GTM maps (generate_lead, add_to_cart) arrive.So the new events above will not be readable either until the pipeline is fixed. The missing piece is GTM mapping + server-side CAPI, not the button. Compounded by the attribution blackout — zero of 278 subscriptions in 90 days carry any attribution — even a click count could not be tied to revenue.
🔴 Do not report "the downsell isn't being used" from an empty dashboard. Until track() is pointed at a real, GTM-mapped layer, an empty number means the pipe is broken, not that the control is unused. Map the custom events in GTM as part of this deploy, or the whole of Step 4 stays unreadable.
The buyer creates their password ON the checkout, exactly like the current quiz register flow. The paybox now carries, above the wallets: Email (prefilled from the quiz, locked) + "Create a password — this logs you into your app".
set_password on the existing account the quiz created at P52 email capture (validate_email / quiz_create_user_with_password — your 11:31 confirmation). The generated password gets replaced by one the buyer knows. The backend already runs exactly this pattern on /register/yearly_2026/.| BUYER | NON-BUYER (abandons the paywall) | |
| Account | Already exists (created at P52). Checkout sets their chosen password (6.15). | Already exists (created at P52), password = the generated one. |
| Credentials | They chose them at checkout — nothing to send beyond the receipt/welcome. | Generated credentials already emailed at capture (SendGrid transactional — existing lane). The exit modal also shows them on-screen (masked, tap-to-show). |
| Next touch | 🔴 NEW — the buyer welcome email (06_EMAIL_BUYER_WELCOME.html), fires on successful payment. Subject: “Your plan knows your push from your pull”. Tags: %FIRSTNAME% %PLANNAME% %AMOUNTCHARGED% %RENEWALDATE% %RENEWALAMOUNT%.
🔴 IT CARRIES NO CREDENTIALS. Buyers set their own password at checkout (§6.15), so it says “log in with the email you used and the password you chose” and explicitly states no password email is coming. Do not add one — sending a buyer hunting for an email that doesn’t exist is the failure mode this wording kills. 🔴 THE FORK IS MUTUALLY EXCLUSIVE. Buyer → welcome email. Non-buyer → free-workouts email. Never both to the same person. | 🔴 NEW — the free-workouts email (04_EMAIL_FREE_WORKOUTS.html). Subject: "Your login is in this email" · one email, no split. Send transactionally to paywall abandoners ~T+1h from last paywall view. Copy is Aga-owned; the trigger and send are yours.
🔴 SCOPE — QUIZ USERS ONLY (Aga, 29 Jul). This email fires only for people who came through the QUIZ and abandoned its paywall. Never app signups, never other lead lanes. A welcome email already goes out for this audience — 750 TMA | Welcome Sequence After Quiz fires at stage-1 email input — so check timing so this lands as its own touch, not a same-hour pile-up. Suppress anyone who PAID. Env note: we're on SiteGround — send from whichever transactional lane already delivers the password emails. 🔴 SUBJECT GATE. The subject promises the login inside works. Do not send until B6 is verified — the quiz-generated password actually logs into the app. If it doesn't, the subject and the credentials card change first. |
| Measured by | purchase + Stripe metadata (Step 4). | paywall_exit_free_store_click · store-link ?ct=paywall_exit / email ?ct=paywall_abandon_email · app activation (list 204 → 988 TMA | Welcome Sequence AFTER APPSIGNUP takes over). |
🔴 One rule across both paths: the free option never appears next to a paid tier. It exists only on exit step 2 and in the abandonment email.
exitBack() originally called exitReset() only — which returns the user to step 1 of the same modal. So Not now → Back → Not now cycled between the two panels forever and never returned anyone to the checkout. Fixed: exitBack() now closes the modal and scrolls to the card fields of the visible model. Carry the fix; do not reintroduce the reset-only version. Same class of defect as the infinite back-button trap in 6.4 — a control that looks like an exit but isn't one.
The mockup now carries a researched behavioural layer. It is presentation only — no pricing or mechanics changed — but several parts need real data or real form attributes from you.
| What | What you must wire |
| “What happens next” billing timeline | 3 steps with REAL computed dates, step 1 marked complete. Dates must come from the actual charge/renewal schedule, not hardcoded. Per tier — annual: today $97 → reminder email → renews $157. Trial: today $0 → email 3 days before → $24.97/mo starts. The reminder-email step is a promise — make sure that email actually sends. |
| Quiz-answer echo block | Render 2–3 of the buyer’s own quiz answers above the price block from quizState (the mockup fakes it from the path chips). Their words, never ours. |
| Absolute expiry datetime beside the countdown | Print the real expiry moment (#obExpAt), server-authoritative — same deadline that kills the $97. Countdown timers are the CMA’s flagship DMCC target (8 investigations opened, 100+ firms written to); a checkable absolute date is both compliant and more persuasive. |
| Equal prominence on billed amounts | Charged-today and renewal figures must sit in the same size/contrast class as the per-day/per-week figure — not muted grey beneath it. Apple 3.1.2 rejects paywalls that promote a normalised price more conspicuously than the billed amount (mass rejections Jan 2026; Cal AI reportedly pulled Apr 2026). Nothing decision-critical below 11px. |
| Encapsulated card block | Keep the distinct container + border around the card fields, and do not reuse that styling anywhere else — Baymard’s finding is that the effect disappears when the treatment is shared. Reassurance stays above the fields (19% abandon over card-detail distrust, and the anxiety spikes at the card interface). |
| 🔴 Form mechanics (Baymard, hard numbers) | autocomplete="cc-number|cc-exp|cc-csc" · inputmode="numeric" · card-type auto-detect + auto-spacing · labels ABOVE fields, never placeholder-only · one field per datum · inline help at the point of doubt. 60% of top-50 mobile sites fail 2 of 5 keyboard optimisations. The sticky CTA must yield when the keyboard takes half the viewport. |
A/B candidates for LATER — do NOT run them now. With ~13 paywall views/day nothing here is powerable (a 30% annual-share test needs ~3.3 years), and zero quiz attribution reaches Stripe. Park these until the pipeline is fixed: (1) 2 visible plans + “view all plans” link — all four published RevenueCat paywall redesigns won by shortening (+31% to +72% install-to-trial); (2) badge on the middle tier vs on the default — MadMuscles deliberately decouples them.
?arm=paywall_v2_6 forces Arm B and does not write to localStorage or cookie.metadata contains quiz_variant and all four UTM keys. Not empty.quiz_variant on quiz_started and as a user property.add_to_cart carries the tier's real price — never 0.duration: once mechanism (defect 7) was never minted.tma_ckt_out is not being set (6.2).At 13 paywall views/day, detecting a +40% revenue-per-visitor difference honestly takes ~143 days. We are not waiting for that, and we are not pretending a 3-week readout is significant.
Both arms are modelled above today, so the test is not "does this work" — it is "which of these two is better". The stop condition is catastrophe only:
If either arm's rolling 14-day revenue per paywall view falls below $6.04 (today's measured baseline), kill that arm and send 100% to the other. Review weekly; expect no verdict on which arm wins before November.
duration: once mechanism) — do this with Step 1. Without it the annual rung cannot charge $97 and the whole ship arm is blocked.tma_ckt_out. 6.1 is the same work as the sticky-CTA fix, so it costs nothing extra once you are in there.Why this outranks everything else in the brief — verified live from Stripe, 29 Jul 2026: annual share did not simply fall, monthly doubled. Feb–Apr: annual 36.0% / quarterly 30.2% / monthly 33.7% (n=258). 10–27 Jul: annual 16.1% / quarterly 19.5% / monthly 64.4% (n=87). The live variant paywall_v2_2 has no annual tier on it at all — this is a missing product, not a presentation problem. And on the Q1-2026 cohort at 4–7 months (270 subs, 1,527 invoices): annual realises $79.04 per subscriber with 69.7% still active; monthly realises $27.99 with 15.6% still active. Every point of mix moved from monthly to annual is worth ~$51 per head already, and the gap is still widening.