---
title: "Paywall"
description: "Projection formula, calendar rules, honesty rules."
source: "design/paywall.md"
---

> Part of [Brand and design](/design/llms.txt). Every page: [llms.txt](/llms.txt).

# Paywall — logic, projection, copy, honesty

Screens: `screens/paywall/paywall--{default,loading-products,purchasing,purchase-failed,restored,expired-return}[.en].html`. The prototype is interactive: open `paywall--default.html`, drag the slider or tap 3/5/7, and switch plans. Logic lives in [`screens/paywall/paywall.js`](/design/gallery/paywall/paywall.js). It reads three embedded JSON blocks with the same shapes as `GET /v1/paywall`, StoreKit `Product`, and the paywall keys of [`strings.json`](/design/strings.json).

## Position in the flow (ADR-001 D8)

diagnostic result → 14-day plan → **paywall (hard)** → trial → home. There is no close button. Nothing past it works without an entitlement (trial or paid). "Restore" stays visible at the top and in the footer. The free diagnostic result is not lost: it is stored server-side and becomes the first point in Growth after subscribing.

## Layout, top to bottom

1. **Your diagnostic:** `diagnostic.overall` in display type, the band label, and the weakest dimension (`weakest_label`, localized).
2. **Title:** "14 days to master “Conclusion first”" (`one_action_title`).
3. **Projection card:** slider 3/5/7 (default = onboarding `weekly_days`), the projection sentence, a start→day-14 bar, the **Pilot data** tag when `benchmark_source = pilot_benchmark`, and the disclaimer "Based on people with a similar start. Your results will differ. Not a promise."
4. **14-day calendar:** Monday-first grid of `plan_preview.days`. Practice days for K are outlined, rest days greyed, today is ink. Markers: streak milestones the schedule can *actually* reach, the billing day, and the day-14 re-test. Below it, a timeline: Today (trial starts) → reminder the day before trial ends → billing date with the StoreKit price → day-14 re-test.
5. **Plans:** annual (default, `badge` from API, trial line, per-month equivalent) and monthly. Fine print, Restore, Terms, Privacy.
6. **CTA dock:** "Start 7-day free trial" / "Subscribe for $13.99/month", with a caption that states the date and amount of the first charge.

## Projection formula

```
N  = baseline score        = paywall.diagnostic.overall        (expired-return: me.stats.ability → CCR-11)
B  = bucket(N)             = "<50" | "50-59" | "60-69" | "70-79" | "80+"
K  = selected weekly days  ∈ {3, 5, 7}
row = benchmarks.find(weekly_days == K && baseline_bucket == B)
if row == null || row.sample_n < 30:        → "not enough people… we won’t predict"  (no number at all)
else:
  M = row.day14_avg_overall,  S = row.sample_n,  Δw = row.weakest_dim_avg_delta
  en: "People who started at {B} and practised {K} days a week averaged {M} after 14 days ({S} people)."
  zh: "起点 {B}的用户，每周练 {K} 天，14 天后平均达到 {M}（样本 {S} 人）。"
  + "{weakest dimension} rose by {Δw} on average."
```

- **No interpolation, no extrapolation.** The server's nightly cohort job owns the numbers. The client only selects a row. The API description says "client interpolates by bucket"; the design deliberately does **not** blend buckets, because a blended number has no real sample behind it.
- The bucket is shown as a range ("60–69"), not as the user's exact score. The cohort is people who *started* in that range.
- Minimum sample is 30 (design rule, `PAYWALL_MIN_SAMPLE`). In the fixtures, bucket `80+` at K=7 has n=18, so a user starting at 80+ who picks 7 days sees "not enough data".
- `benchmark_source = pilot_benchmark` always renders the localized `benchmark_label` ("Pilot data" / 试点数据). With `cohort` the tag disappears, but the disclaimer stays.

Fixture values (pilot, `docs/api/examples/*/paywall.json`), bucket 60–69: K3 → 70 (n 86), K5 → 74 (n 212), K7 → 77 (n 64).

## Calendar rules

- Practice pattern per K, by plan-day index within each 7-day block: K3 = {0, 2, 4}, K5 = {0, 1, 2, 4, 5}, K7 = all. Day 1 (today) is always a practice day.
- Streak milestones are computed from that pattern (3 and 7 consecutive practice days). K5 shows a "3" on days 3 and 10 and never a "7". K3 shows none. We never promise a streak the schedule can't produce.
- Billing day = day `trial_days + 1` (index `trial_days`) for products with an eligible intro offer; otherwise today. The date and `displayPrice` both come from StoreKit.
- Trial reminder = day before billing. It is a local notification scheduled at purchase if notifications are on, and the timeline says so.

## Prices and products

- Prices, currencies, periods and trial length are **only** read from StoreKit (`displayPrice`, `price`, `subscriptionPeriod`, `introductoryOffer`, `isEligibleForIntroOffer`). Copy uses placeholders, never amounts. The fixture uses ¥396/¥60 (zh storefront) and $79.99/$13.99 (US storefront) as samples only; these mirror the intended App Store Connect prices (doubled 2026-10-17).
- The per-month equivalent for annual is `price / 12` formatted with the product's `priceFormatStyle`. It is labelled "about".
- `products[].is_default` selects the default card. `badge` is server-localized ("Best value" / 最划算). No other badges.
- Purchase: `Product.purchase()` → verify JWS server-side via `POST /v1/subscriptions/apple/transactions` → trust `Entitlement` from the response. Restore: `AppStore.sync()` → same endpoint per transaction.

## States

| State | What changes |
|---|---|
| loading-products | Product cards are skeletons and the CTA is disabled ("Loading prices…"). The projection and calendar work, since they come from `/v1/paywall`. |
| purchasing | CTA spinner "Connecting to the App Store…", cards locked. The system sheet is on top. |
| purchase-failed | Red inline alert: "Purchase didn’t go through. You weren’t charged." CTA = Retry. A user cancel uses the same alert with cancel copy and no error tone. |
| restored | Toast "Subscription restored", CTA "Start today’s session". |
| expired-return | "Mia, your trial ended on Oct 24" plus the user's own stats (sessions, ability, focus). The projection uses current ability. No trial (not eligible), CTA "Subscribe for $79.99/year". |

## What makes it honest (ADR-001 D10)

Used:

- The user's **own** diagnostic score and weakest area. Nothing generic.
- Cohort numbers with **sample size**, a **pilot label**, a **minimum-sample cut-off**, and a "not a promise" disclaimer.
- The **exact first-charge date and amount** under the CTA and in the timeline, plus a reminder before the trial ends.
- A calendar that only shows milestones the selected schedule can reach.
- Restore, Terms, Privacy and cancellation instructions always visible.

Not used:

- No countdown timers, "offer ends", or fake scarcity.
- No decoy tier, no crossed-out "original" prices, no "save 83 %" against an inflated reference. The per-month equivalent is plain arithmetic.
- No pre-selected add-ons, no hidden close button, no shame copy ("No thanks, I don't want to improve").
- No social proof unless real (`social_proof.*` is null in pilot, so it's hidden).
- No auto-start of a purchase. Every purchase needs the system sheet confirmation.

## Analytics (D12)

`paywall_view {baseline_bucket, benchmark_source}` · `paywall_interact {control: weekly_days|product, value}` · `trial_start` / `purchase {product_id}` · failures carry `error_code`. No prices or content in events.
