From 4983a9c019c83485a1194d90079caf8a2e641d3d Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Wed, 19 Aug 2026 18:16:53 +0200 Subject: [PATCH 1/7] feat: add framework docs --- content/docs/framework/actions.mdx | 70 ++++++++ content/docs/framework/assets.mdx | 128 ++++++++++++++ content/docs/framework/cli.mdx | 112 ++++++++++++ content/docs/framework/config.mdx | 103 +++++++++++ content/docs/framework/examples.mdx | 80 +++++++++ content/docs/framework/hooks.mdx | 170 +++++++++++++++++++ content/docs/framework/index.mdx | 65 +++++++ content/docs/framework/lifecycle.mdx | 84 +++++++++ content/docs/framework/localization.mdx | 77 +++++++++ content/docs/framework/meta.json | 41 +++++ content/docs/framework/navigation.mdx | 125 ++++++++++++++ content/docs/framework/products.mdx | 128 ++++++++++++++ content/docs/framework/project-structure.mdx | 71 ++++++++ content/docs/framework/purchases.mdx | 101 +++++++++++ content/docs/framework/push-and-promote.mdx | 97 +++++++++++ content/docs/framework/quickstart.mdx | 146 ++++++++++++++++ content/docs/framework/studio.mdx | 52 ++++++ content/docs/framework/styling.mdx | 84 +++++++++ content/docs/framework/transitions.mdx | 103 +++++++++++ content/docs/framework/trials.mdx | 90 ++++++++++ content/docs/framework/troubleshooting.mdx | 82 +++++++++ content/docs/framework/variables.mdx | 74 ++++++++ content/docs/framework/web-checkout.mdx | 88 ++++++++++ content/docs/meta.json | 1 + src/components/DocsHeader.tsx | 2 + src/lib/llms.ts | 4 + src/routeTree.gen.ts | 42 +++++ src/routes/framework/llms-full[.]txt.ts | 10 ++ src/routes/framework/llms[.]txt.ts | 10 ++ src/routes/index.tsx | 19 +++ 30 files changed, 2259 insertions(+) create mode 100644 content/docs/framework/actions.mdx create mode 100644 content/docs/framework/assets.mdx create mode 100644 content/docs/framework/cli.mdx create mode 100644 content/docs/framework/config.mdx create mode 100644 content/docs/framework/examples.mdx create mode 100644 content/docs/framework/hooks.mdx create mode 100644 content/docs/framework/index.mdx create mode 100644 content/docs/framework/lifecycle.mdx create mode 100644 content/docs/framework/localization.mdx create mode 100644 content/docs/framework/meta.json create mode 100644 content/docs/framework/navigation.mdx create mode 100644 content/docs/framework/products.mdx create mode 100644 content/docs/framework/project-structure.mdx create mode 100644 content/docs/framework/purchases.mdx create mode 100644 content/docs/framework/push-and-promote.mdx create mode 100644 content/docs/framework/quickstart.mdx create mode 100644 content/docs/framework/studio.mdx create mode 100644 content/docs/framework/styling.mdx create mode 100644 content/docs/framework/transitions.mdx create mode 100644 content/docs/framework/trials.mdx create mode 100644 content/docs/framework/troubleshooting.mdx create mode 100644 content/docs/framework/variables.mdx create mode 100644 content/docs/framework/web-checkout.mdx create mode 100644 src/routes/framework/llms-full[.]txt.ts create mode 100644 src/routes/framework/llms[.]txt.ts diff --git a/content/docs/framework/actions.mdx b/content/docs/framework/actions.mdx new file mode 100644 index 00000000..804405ef --- /dev/null +++ b/content/docs/framework/actions.mdx @@ -0,0 +1,70 @@ +--- +title: "Actions" +description: "Close the paywall, restore purchases, open links, request OS permissions, and call back into your app — everything a paywall asks its host to do." +--- + +A paywall runs inside your app, and some things only the host can do: dismiss the paywall, open a link, prompt for a permission, run your app's code. All of it goes through `useActions()`: + +```tsx +import { useActions } from "superwall/hooks"; + +const { close, restore, openUrl, requestPermission, requestCallback } = useActions(); +``` + +## The actions + +| Action | Use it for | +| --- | --- | +| `close()` | Closing the paywall — the X button. Closing is not navigation. | +| `restore()` | Restore purchases. Fire-and-forget: success arrives as a `transaction_complete` event or a dismissed paywall — there is no return value to await. See [Purchases](/framework/purchases). | +| `openUrl(url)` | Terms, privacy, any link. **Always this, never ``.** | +| `openExternalUrl(url)` | Open in the system browser instead of in-app. | +| `openDeepLink(link)` | Deep link into the app. | +| `customPlacement(name, params?)` | Fire a Superwall placement — which can present another paywall. | +| `requestPermission(type)` | OS permission prompt. Resolves `"granted" \| "denied" \| "unsupported"`. | +| `requestCallback(name, options?)` | Run **your app's** code and await its answer. Resolves `{ status: "success" \| "failure", data? }`. | +| `requestStoreReview("in-app" \| "external")` | Store review prompt. | + + +Links go through `openUrl`, never an ``. Inside a webview, an anchor either does nothing or navigates the paywall away from itself — `openUrl` hands the URL to the host so it opens the way the platform expects. + + +Closing works the same way: the paywall lives on a navigation stack of its own pages, but *leaving* the paywall isn't a navigation — it's `close()`. See [Pages & navigation](/framework/navigation). + +## Permissions + +```tsx +const status = await requestPermission("notification"); +// "granted" | "denied" | "unsupported" +``` + +Permission types: `notification`, `camera`, `microphone`, `location`, `background_location`, `contacts`, `read_images`, `read_video` (Android only), `tracking`. + +## Callbacks — ask your app a question + +A callback runs code *in your app* and hands the answer back to the paywall — anything the paywall cannot know on its own: does this account exist, is this referral code valid, what did the user pick during signup. + +```tsx +const result = await requestCallback<{ exists: boolean }>("checkAccount"); + +if (result.status === "success" && result.data?.exists) { + router.push("welcome-back"); +} +``` + +Type the answer with a claim, as above — the generic is your statement of what the app returns. + +### Permission vs callback + +A **permission** asks the OS; a **callback** asks your app. Both resolve from code the paywall does not control, which shapes how you use them: + +- **Show something while they run.** The OS prompt or your app's code takes as long as it takes. +- **Treat a denial as an ordinary outcome**, not an error. A user who declines notifications is still a user — design the path that continues without. + +## In development + +In `superwall dev`, actions don't reach a real host — they're logged in the studio's event log, and permission, callback, and purchase requests prompt **you** to pick the outcome. That makes both branches of every flow testable before a device ever sees it. See [The studio](/framework/studio). + + +The permissions example shows `requestPermission` and `requestCallback` side by side, with a denial treated as an outcome rather than an error. See [Examples](/framework/examples). + diff --git a/content/docs/framework/assets.mdx b/content/docs/framework/assets.mdx new file mode 100644 index 00000000..2dbe77f1 --- /dev/null +++ b/content/docs/framework/assets.mdx @@ -0,0 +1,128 @@ +--- +title: "Assets" +description: "Add images, video, audio, fonts, and animations to a paywall by importing files. The build handles optimization, hosting, and caching." +--- + +Add media to a paywall by importing files from an `assets/` directory. The build handles optimization, hosting, and caching; there is nothing to configure and no upload step. + +## Where assets live + +Assets follow the same two-level pattern as components and messages: shared at the project root, local inside a paywall. + +```ts +superwall/ +├── assets/ shared across every paywall +└── paywalls/pro/ + └── assets/ this paywall's own +``` + + +Every asset belongs in an `assets/` directory — `superwall/assets/` for shared files, `superwall/paywalls//assets/` for one paywall's own. If a large asset lives anywhere else, the build fails and names the file. + + +## Use an image + +Import the file and use it like any URL: + +```tsx +import hero from "@/assets/hero.jpg"; // shared: superwall/assets/ +import badge from "../assets/badge.png"; // this paywall's own + + +``` + +CSS `url()` works the same way. Imports typecheck because of the generated `superwall.d.ts` — one more reason to [commit it](/framework/project-structure). + +Supported out of the box: + +| Kind | Formats | +| --- | --- | +| Images | `png` `jpg` `jpeg` `webp` `avif` `gif` `svg` `ico` `apng` | +| Video | `mp4` `webm` `mov` `m4v` | +| Audio | `mp3` `m4a` `aac` `wav` `ogg` | +| Fonts | `woff2` `woff` `ttf` `otf` | +| Animation & 3D | `lottie` `riv` `glb` | + +`?url`, `?raw`, and `?inline` import suffixes work too, as do CSS modules. + +## How hosting works + +You never choose where an asset is served from — the build decides, and nothing about your code changes either way: + +- **Video, audio, and fonts** are always served from Superwall's CDN, whatever their size. Video streams properly instead of being carried by the paywall, and one upload is reused across every version of every paywall. +- **Images** embed in the paywall when small and move to the CDN when large. + +```tsx +import promo from "../assets/promo.mp4"; + +` ([Actions](/framework/actions)). +- **Light and dark via the `:root.dark` class**, both always checked; safe areas with sensible minimums; responsive from 320px to tablet. diff --git a/content/docs/framework/hooks.mdx b/content/docs/framework/hooks.mdx new file mode 100644 index 00000000..71090453 --- /dev/null +++ b/content/docs/framework/hooks.mdx @@ -0,0 +1,170 @@ +--- +title: "Hooks Reference" +description: "Every hook the framework provides — signatures, what each returns, and the semantics that matter." +--- + +Everything a paywall reads or triggers comes through hooks. One concern each — there is deliberately no kitchen-sink hook. + +```tsx +import { + useProducts, usePurchase, useActions, useHaptics, useTranslation, + useTrialEligibility, useDevice, useUser, useVariables, useColorScheme, + useSuperwallEvent, useSuperwallSnapshot, useSuperwallSession, + type ProductReference, +} from "superwall/hooks"; + +import { useRouter, useIsFocused } from "superwall/navigation"; +``` + +## `useProducts()` + +```tsx +const { products, getProduct } = useProducts(); +const annual = getProduct("annual"); // typed reference — typos are compile errors +``` + +Products, keyed by the reference declared in `config.ts`, each carrying store-owned `variables` (`price`, `period`, `trialPeriodDays`, …). A declared reference always exists, but its variables may not have arrived — guard every read and design the empty state. The full variable list and reading rules are in [Products](/framework/products). + +## `usePurchase()` + +```tsx +const { purchase, prefetch, isPurchasing, transaction, failure } = usePurchase(); + +const result = await purchase("annual"); +// { status: "completed" | "abandoned" | "failed" } — never throws for flow outcomes +``` + +The whole purchase flow — outcomes, the no-loading-state rule, web checkout, and `prefetch` — is in [Purchases](/framework/purchases). + +## `useActions()` + +```tsx +const { close, restore, openUrl, requestPermission, requestCallback } = useActions(); +``` + +Also on the object: `openExternalUrl`, `openDeepLink`, `customPlacement`, `requestStoreReview`. Everything a paywall asks its host to do — [Actions](/framework/actions) has the full table, the permission types, and the callback pattern. + +## `useHaptics()` + +```tsx +const haptics = useHaptics(); +haptics.light(); // navigation, CTAs +haptics.selection(); // changing a choice +haptics.success(); // purchase landed +``` + +Also available: `medium`, `heavy`, `warning`, `error`. Fire one on every meaningful tap — iOS produces no feedback of its own inside a paywall. No-ops where haptics are unavailable, so call them unconditionally. + +## `useTranslation()` + +```tsx +const { t, locale, setLocale, locales } = useTranslation(); +t("paywall.cta", { price }); +``` + +Localized copy from `messages/.ts` catalogs — the catalog system, fallback rules, and interpolation are in [Localization](/framework/localization). + +## `useTrialEligibility()` + +```tsx +const { eligible } = useTrialEligibility(); // boolean | undefined — the store decides +``` + +`undefined` until the SDK reports, so gate trial-only UI on `eligible === true`. Splits the paywall into eligible and ineligible versions — both must read as intentional. See [Free trials](/framework/trials). + +## `useVariables()` + +```tsx +const { device, user, params } = useVariables(); +``` + +Everything the app and SDK told this paywall about the presentation: the SDK-filled `device` record, `user` attributes your app set, and the placement's `params`. All three are host-filled — guard every read. The records, fields, and guarding doctrine are in [Variables & personalization](/framework/variables). + +## `useUser()` + +```tsx +const user = useUser(); +``` + +Shorthand for `useVariables().user` when the device and params records aren't needed. + +## `useDevice()` + +```tsx +const { orientation, platform, deviceModel } = useDevice(); +``` + +The same device record as `useVariables().device`, plus `orientation` (`"portrait" | "landscape"`) — measured in the page, so it updates the moment the device turns. See [Variables & personalization](/framework/variables). + +## `useColorScheme()` + +```tsx +const scheme = useColorScheme(); // "light" | "dark" +``` + +Rarely needed: the framework already keeps a `dark`/`light` class on `` from what the device reports, so style with plain CSS (`:root.dark { … }`). Reach for the hook only when you need the scheme in JavaScript. Never use `@media (prefers-color-scheme: dark)` as the mechanism — see [Styling & mobile design](/framework/styling). + +## `useSuperwallEvent(name, handler)` + +```tsx +useSuperwallEvent("transaction_complete", () => haptics.success()); +``` + +Typed SDK events, subscribed for the component's lifetime; an inline arrow handler is fine. The event list and when each fires: [Lifecycle & events](/framework/lifecycle). For anything a dedicated hook covers (products, trial, variables), use the hook — it cannot miss data that arrived before your component subscribed. + +## `useSuperwallSnapshot()` + +```tsx +const snapshot = useSuperwallSnapshot(); +const opened = snapshot.paywall !== undefined; +``` + +The whole runtime state as one subscribed object. Its most common use is gating entry animations on presentation — paywalls are preloaded hidden, and `snapshot.paywall` flips when the paywall is actually shown ([Lifecycle & events](/framework/lifecycle)). It also carries `experiment` (the A/B assignment), `locale`, and the current purchase and transaction state. + +## `useSuperwallSession()` + +```tsx +const session = useSuperwallSession(); +session.setUserAttributes({ onboardingCompleted: "true" }); +``` + +The full session for advanced work — the few methods no hook surfaces (`setUserAttributes`, raw protocol messaging) and use outside React components. If you're reaching for it for products, purchases, actions, or events, use the dedicated hook instead. + +## `useRouter()` + +```tsx +import { useRouter } from "superwall/navigation"; + +const router = useRouter(); +router.push("plans"); +router.replace("terms"); +router.back(); +router.canGoBack(); +router.dismiss(2); +router.dismissAll(); +router.dismissTo("goals"); +router.name; // current page +router.depth; // pages underneath (index = 0) +``` + +The stack router for multi-page flows — expo-router's API, method for method. Page names autocomplete and reject typos via the generated `superwall.d.ts`. [Pages & navigation](/framework/navigation) covers the stack model, state between pages, and shared chrome. + +## `useIsFocused()` + +```tsx +import { useIsFocused } from "superwall/navigation"; + +const focused = useIsFocused(); +``` + +Whether this page is on top of the stack. Pages you navigate away from stay alive — a covered page can't be clicked or focused, and `useIsFocused()` tells it so, so it can pause video or timers. See [Pages & navigation](/framework/navigation). + +## `ProductReference` + +```tsx +import { type ProductReference } from "superwall/hooks"; + +const [selected, setSelected] = React.useState("annual"); +``` + +The union of product references declared in your `config.ts` — the type behind `getProduct`, `purchase`, and `prefetch`. Use it for selection state so an invalid reference is a compile error. diff --git a/content/docs/framework/index.mdx b/content/docs/framework/index.mdx new file mode 100644 index 00000000..923f4d06 --- /dev/null +++ b/content/docs/framework/index.mdx @@ -0,0 +1,65 @@ +--- +title: "Superwall Framework" +description: "Build paywalls, onboarding funnels, and web checkout flows as React mini-apps — in your repo, with your tools, shipped without an app update." +--- + +The Superwall Framework lets you build paywalls, onboarding funnels, and web checkout flows as code. Each one is a small React app in a `superwall/` directory inside your repo: a `config.ts` that declares its name and products, an `app/` directory of pages, and whatever components, styles, and assets it needs. Superwall provides everything else — products, purchases, localization, trial handling, and the bridge to the native SDKs — so you never touch native code to change what your users see. + +```ts +superwall/paywalls/pro/ +├── config.ts definePaywall({ name, products: { annual: "pro_5999_year" } }) +├── app/ pages — index.tsx, plans.tsx, layout.tsx +├── components/ everything that is not a page +└── messages/en.ts localized strings, discovered by filename +``` + +You preview locally with `superwall dev`, which opens a studio with device frames, light/dark toggles, locale switching, and simulated purchases. When you're happy, `superwall push` seals an immutable version, and `superwall promote` points production at it — your users get the new paywall on the next open, no app review required. + + +The framework requires the **headless paywalls** feature to be enabled on your Superwall application. If a push tells you it isn't, contact us to have it turned on. + + +## Why code-first? + +- **It's just React.** State, components, hooks, CSS — nothing to relearn, and your existing component patterns carry over. Use Tailwind, Motion, Rive, or plain CSS; the framework doesn't care. +- **Version-controlled and reviewable.** Paywalls live in your repo, go through your PR process, and ship from CI if you want them to. +- **Ship without app releases.** Pushed paywalls are delivered remotely by the same SDKs you already use. Promote a new version — or roll back — in seconds. +- **Store data stays store-owned.** Prices, periods, and trials come from the App Store, Google Play, or Stripe at runtime, localized and formatted for each user. You never hardcode a price. +- **One flow, many steps.** Multi-page onboardings and funnels are a single paywall whose steps are pages on a navigation stack — no network between steps, no loading spinners. + +## How it fits together + +| Piece | What it does | +| --- | --- | +| `superwall` (npm) | The framework: `definePaywall`, hooks, navigation, the build | +| `superwall` (CLI) | `create`, `dev`, `push`, `promote`, `publish` | +| The studio | Local preview at `localhost:6100` — devices, themes, locales, simulated outcomes | +| The dashboard | Where pushed paywalls, versions, and products live; campaigns decide who sees what | +| The native SDKs | Present your paywall in-app, deliver product data, run purchases | + +Your app keeps presenting paywalls exactly as it does today — through placements and campaigns. The framework changes how paywalls are *built*, not how they're *shown*. + +## Start here + + + + Scaffold a project, preview your first paywall in the studio, and ship it. + + + How a `superwall/` directory is laid out and which files to commit. + + + Build multi-page flows with file-based routes and a stack router. + + + Declare products, read live prices, and handle every purchase outcome. + + + +## Go deeper + +- **[Configuration](/framework/config)** — everything `definePaywall` accepts, from presentation style to trial reminders. +- **[Web checkout](/framework/web-checkout)** — sell the same paywall on the web with one config key. +- **[Variables & personalization](/framework/variables)** — react to user attributes, device state, and placement parameters. +- **[Localization](/framework/localization)** — one file per locale, picked automatically from the device. +- **[Examples](/framework/examples)** — complete standalone projects, each teaching one idea. diff --git a/content/docs/framework/lifecycle.mdx b/content/docs/framework/lifecycle.mdx new file mode 100644 index 00000000..2efea651 --- /dev/null +++ b/content/docs/framework/lifecycle.mdx @@ -0,0 +1,84 @@ +--- +title: "Lifecycle & Events" +description: "What a paywall knows and when — the preload rule that shapes every entry animation, and the SDK events you can react to." +--- + +The SDK **preloads paywalls hidden** before showing them. Your components mount long before anyone is looking — so a mount-timed animation (a `useEffect` on mount, Motion's `initial`/`animate` firing on mount, a CSS animation on load) has already finished by the time the paywall appears. This one fact shapes every entry animation you'll write. + +## Gate entry animations on presentation, never mount + +The presentation signal is `useSuperwallSnapshot().paywall` — it flips from `undefined` when the paywall is actually shown: + +```tsx +import { useSuperwallSnapshot } from "superwall/hooks"; + +const opened = useSuperwallSnapshot().paywall !== undefined; + + +``` + +Unlike an event listener added in an effect, the snapshot cannot miss the moment — it reads current state rather than waiting to be told. + +Value-driven animations gate on **both** conditions. A price count-up starts when `opened && raw !== undefined` — never when the store delivers the price (it would play while hidden), and never on a missing value (it would land on a made-up figure): + +```tsx +const rawPrice = Number(annual?.variables.rawPrice); +const raw = Number.isFinite(rawPrice) ? rawPrice : undefined; + +React.useEffect(() => { + if (opened && raw !== undefined) { + const controls = animate(price, raw, { duration: 0.9, ease: "circOut" }); + return () => controls.stop(); + } +}, [opened, raw]); +``` + +The with-motion [example](/framework/examples) is the reference for both patterns. The ownership rule that goes with them: animation libraries animate *inside* a page — moving *between* pages is the router's job, so spamming navigation can never fight your component animations. See [Transitions](/framework/transitions). + +## Events you can react to + +```tsx +import { useSuperwallEvent } from "superwall/hooks"; + +useSuperwallEvent("transaction_complete", () => haptics.success()); +useSuperwallEvent("freeTrial_start", () => { /* trial began */ }); +``` + +| Event | Fires when | +| --- | --- | +| `paywall_open` | The paywall is presented (or re-presented). Prefer `snapshot.paywall` for anything render-driving. | +| `transaction_complete` | A purchase **or restore** succeeded — whoever started it. | +| `transaction_abandon` | The store sheet was closed. | +| `freeTrial_start` | A trial actually began. Also triggers the configured [trial reminder](/framework/trials). | +| `experiment` | The experiment assignment arrived (`experimentId`, `variantId`, `campaignId`). | +| `back_button_input` | Android hardware back. | +| `game_controller_input` | Controller input — needs `gameControllerEnabled: true` in [config](/framework/config). | +| `message` | Every incoming SDK message — the debugging firehose. | + +Subscriptions last the component's lifetime; an inline arrow handler is fine. + + +For products, variables, and trial eligibility, use the dedicated hooks instead of events — they read current state and cannot miss data that arrived before your component subscribed. Data arrives progressively after open (paywall id → products → variables → trial eligibility → experiment), which is one more reason every read is guarded. + + +## Dark mode + +The device decides; the framework maintains a `dark`/`light` class on ``. Style with plain CSS and write no wiring: + +```css +:root { --bg: #fdfef6; --fg: #0c0b0a; } +:root.dark { --bg: #1c1b19; --fg: #fdfef6; } +``` + +Don't use `@media (prefers-color-scheme: dark)` as the mechanism — it cannot see what the device reports and ignores the studio's theme toggle. The class is the mechanism. [Styling & mobile design](/framework/styling) has the full treatment, including Tailwind. + +## Dev vs device + +The same paywall runs against a simulated host in `superwall dev` and the real SDK on device — purchases are simulated in one and real in the other, product variables are injected by the studio in one and delivered by the SDK on the other, and numeric variables arrive as **strings** on device. The full comparison table is in [The studio](/framework/studio). + +## The platform stylesheet + +Published paywalls receive a small Superwall-owned stylesheet at serve time — platform-wide behavior like scroll control. Previews apply the same one, so local and published render identically. Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer. diff --git a/content/docs/framework/localization.mdx b/content/docs/framework/localization.mdx new file mode 100644 index 00000000..c8dbe83e --- /dev/null +++ b/content/docs/framework/localization.mdx @@ -0,0 +1,77 @@ +--- +title: "Localization" +description: "Ship a paywall in multiple languages by adding one file per locale — no registration, no wiring." +--- + +Ship a paywall in multiple languages by adding one file per locale. The filename is the locale, and the device picks which one renders — no registration, no wiring. + +## Add locales + +Message catalogs live in `messages/` directories, at the same two levels as components and assets: + +```ts +superwall/ +├── messages/ shared by every paywall +│ ├── en.ts +│ └── de.ts +└── paywalls/pro/ + └── messages/ this paywall's own + ├── en.ts + └── fr.ts +``` + +Each file default-exports a nested object: + +```ts +// paywalls/pro/messages/fr.ts +export default { + paywall: { + title: "Passez à Pro", + cta: "S'abonner · {price}", + perMonth: "{price} par mois, facturé annuellement", + }, +} as const; +``` + +A paywall's own catalog layers over the shared one — it overrides the keys it names and inherits the rest. A locale can exist in either layer or both. + +If your fallback language isn't English, set it in `config.ts`: + +```ts +localization: { defaultLocale: "en" }, +``` + +## Use the strings — `useTranslation()` + +```tsx +const { t, locale, setLocale, locales } = useTranslation(); + +

{t("paywall.title")}

+ + +``` + +- **`t(key, values?)`** — the translated string for the active locale. Interpolation is `{name}` in the catalog with `t(key, { name: value })` at the call site. +- **`locale`** — the active locale, resolved from the device. Resolution is specific-to-general: `pt-BR` matches a `pt-BR` catalog first, then `pt`, then the default locale. +- **`setLocale(locale)`** — override the device; `setLocale(undefined)` returns to auto-detection. This is for previews and tests — on device, the system setting is the truth. +- **`locales`** — every locale that has a catalog. + +## How fallbacks behave + +- A key missing from the active locale falls back to the default locale **per key** — a partial translation stays usable while it's being finished. +- An unknown key renders as itself, so `t()` never breaks. The flip side: **key typos are invisible at runtime** — nothing throws, the key just shows up on screen. Check your copy in [the studio](/framework/studio) with its locale switcher. +- Guard interpolations on the value existing, with a bare-key fallback — as in the CTA above. Never render "Subscribe · undefined". + +## The rules + +- **Never put a price in a catalog.** Prices are localized by the store — the SDK delivers the right currency and format for the user's region. Interpolate them: `"Subscribe · {price}"`. See [Products](/framework/products). +- **No language picker on device.** The locale is the person's system setting; preview other locales with the studio's locale switcher. +- **Copy expands.** German runs long — size nothing to fit English. +- Product `period` and `periodly` variables ("yearly" → "jährlich") localize automatically in 44 languages, independent of your catalogs. +- A single-locale paywall needs none of this — plain strings in JSX are fine until the second locale arrives. + + +There is no plural engine — no ICU, no `_one`/`_other` suffixes. Write around plurals, or fork on the count yourself. + + +The localization [example](/framework/examples) shows four locales, both catalog layers, and guarded interpolation. diff --git a/content/docs/framework/meta.json b/content/docs/framework/meta.json new file mode 100644 index 00000000..df0c84a8 --- /dev/null +++ b/content/docs/framework/meta.json @@ -0,0 +1,41 @@ +{ + "title": "Framework", + "icon": "Code", + "root": true, + "pages": [ + "---Get Started---", + "index", + "quickstart", + "project-structure", + + "---Building Paywalls---", + "config", + "navigation", + "transitions", + "styling", + "assets", + "localization", + + "---Monetization---", + "products", + "purchases", + "trials", + "web-checkout", + + "---Connecting to Your App---", + "variables", + "actions", + "lifecycle", + + "---Shipping---", + "studio", + "push-and-promote", + + "---Reference---", + "hooks", + "cli", + "examples", + "troubleshooting", + "[Example Projects](https://github.com/superwall/superwall/tree/main/examples)" + ] +} diff --git a/content/docs/framework/navigation.mdx b/content/docs/framework/navigation.mdx new file mode 100644 index 00000000..65e5d480 --- /dev/null +++ b/content/docs/framework/navigation.mdx @@ -0,0 +1,125 @@ +--- +title: "Pages & Navigation" +description: "Build multi-page paywalls, onboardings, and funnels with file-based pages and a stack router — no network between steps, no loading spinners." +--- + +Multi-page paywalls, onboarding quizzes, and funnels are built from file-based pages and a stack router. Moving between pages never touches the network — the whole flow ships together, so there's no page load, no spinner, and no screen that never arrives. + +## Add pages + +Every `.tsx` file in `app/` is a page; directories nest the name: + +```ts +app/ +├── index.tsx "index" — every flow starts here +├── plans.tsx "plans" +├── layout.tsx wraps every page (the one reserved name) +└── goals/ + ├── index.tsx "goals" + └── setup.tsx "goals/setup" +``` + +File names are lowercase-kebab, and each page default-exports a component. Components that aren't pages go in `components/`, not `app/` — a stray file there is a warning in dev and blocks a push. + + +Only the top-level `layout.tsx` is special. A nested `goals/layout.tsx` would become a page named `goals/layout` — there are no nested layouts. + + +## Navigate + +```tsx +import { useRouter } from "superwall/navigation"; + +const router = useRouter(); + +router.push("goals/setup"); // forward +router.push("plans", { transition: "fade" }); // with a transition +router.replace("terms"); // swap the current page +router.back(); // one step back +router.canGoBack(); // anything to go back to? +router.dismiss(2); // back two steps +router.dismissAll(); // back to the first page +router.dismissTo("goals"); // unwind to a page in the stack + +router.name; // current page +router.depth; // pages underneath (index = 0) +``` + +If you've used expo-router, this is its API, method for method. Page names autocomplete and reject typos, thanks to the generated `superwall.d.ts` — one more reason to [commit it](/framework/project-structure). + +A few rules make navigation feel right: + +- **Closing the paywall is `useActions().close()`**, not navigation. The stack is for moving within the flow; closing hands control back to your app. See [Actions](/framework/actions). +- **Fire `haptics.light()` before every push and back.** iOS gives no feedback of its own on navigation inside a paywall. +- **Pages you navigate away from stay alive.** Going back restores a page exactly as it was left, scroll position and state included. A covered page can't be clicked or focused; `useIsFocused()` tells a page it's covered so it can pause video or timers. +- **There is no declared page order.** Any page can push any page — which is exactly what makes branching flows possible. +- **Page views are tracked for you.** Every navigation reports analytics automatically; there's nothing to instrument. + +## Pass state between pages + +Navigation carries no params, on purpose. Cross-page state has two homes: + +**`layout.tsx`** stays mounted for the whole flow — React state or context there is visible to every page: + +```tsx +export default function Layout({ children }: PropsWithChildren) { + return
{children}
; +} +``` + +**A plain module** works even after the collecting page is gone — the quiz pattern, from the onboarding quiz example (see [Examples](/framework/examples)): + +```ts +// components/answers.ts +export const answers: { goal?: Goal; level?: Level } = {}; +``` + +```tsx +const choose = (value: Goal) => { + haptics.selection(); + answers.goal = value; + router.push("level"); +}; +``` + +Guard every read on the destination — `answers.goal ? PLAN[answers.goal] : undefined` — so a revisited page never crashes on a missing answer. + +## Shared chrome + +Put back buttons, step counters, and the close button in `layout.tsx`, and drive them from router state so they can never drift from the stack: + +```tsx +const router = useRouter(); + +{router.canGoBack() + ? + : } /* placeholder keeps the layout stable */ +{router.depth + 1} of 3 +``` + + +`depth + 1` works as a step counter only in linear flows. In a branching flow, a page's depth isn't its step number — label steps per page instead. + + +When the layout wraps chrome around the pages, set two variables in `:root`: + +```css +:root { + --sw-background: var(--bg); /* pages are opaque; give them your background */ + --sw-routes-height: auto; /* let the layout own the height, or its footer is pushed off-screen */ +} +``` + +Position overlay chrome absolutely *over* the pages rather than as a bar above them — each page paints its own background, so a bar of its own shows as a seam during transitions. + +## A funnel is one paywall, not several + +Multi-step flows — onboarding quizzes, web funnels — are **one paywall whose steps are pages**, not a chain of separate paywalls. Every step is a `router.push` in the same flow, so there's no load between steps and nothing to re-fetch. The structure is identical — `config.ts` plus `app/` pages plus `layout.tsx` — and funnels live in `superwall/funnels//` with exactly the same shape. + +The web funnel example is the reference: question steps, a typed plan selector, then `purchase(reference)` at the end — with [web checkout](/framework/web-checkout) taking payment in the same flow. + +## Where transitions and animation fit + +How pages move — the built-in transitions, custom ones, and bottom sheets — is covered in [Transitions](/framework/transitions). Animation *inside* a page (Motion, CSS) is yours; moving *between* pages stays the router's job. Keeping that line means spamming navigation can never fight your component animations. And entry animations gate on presentation, never mount — see [Lifecycle & events](/framework/lifecycle). + +Assets for upcoming pages preload automatically while the user is on the current page — see [Assets](/framework/assets). diff --git a/content/docs/framework/products.mdx b/content/docs/framework/products.mdx new file mode 100644 index 00000000..c221c2ac --- /dev/null +++ b/content/docs/framework/products.mdx @@ -0,0 +1,128 @@ +--- +title: "Products" +description: "Declare product slots in config.ts, read live store data through useProducts, and follow the three rules that keep prices honest." +--- + +Products connect your paywall to the things it sells. You declare them once in `config.ts`, and everything about them — price, period, trial — arrives from the store at runtime, localized and formatted for each user. You never hardcode a price. + +## Declare products + +Products are **slots**. The key is the reference your code uses; the value is the store identifier: + +```ts +import { definePaywall } from "superwall/config"; + +export default definePaywall({ + name: "Pro", + products: { + monthly: "pro_999_month", + annual: "pro_5999_year", + }, +}); +``` + +The shorthand string and the object form mean the same thing: + +```ts +products: { + annual: "pro_5999_year", // shorthand + monthly: { productId: "pro_999_month" }, // same thing +}, +``` + +Your code only ever speaks in references — `getProduct("annual")`, `purchase("annual")` — so swapping the underlying store product is a one-line config change. + +### Web and Stripe products + +Web paywalls sell through Stripe, and the Stripe price lives inside the identifier — no separate mapping. The format is `{environment}:{priceId}:{offer}`: + +```ts +products: { + monthly: "live:price_1ABC…:7days-free", +}, +``` + +A paywall can declare both kinds side by side — store products for native, Stripe products for the web. See [Web checkout](/framework/web-checkout) for how the same `purchase()` call sells on both. + +### Product data never appears in the file + +Price, period, and trial are store-owned and arrive at runtime. `superwall push` refuses to publish a reference the dashboard has no product for — every variable on it would be `undefined` on device. Example identifiers in scaffolds and examples are placeholders to repoint at your own products. + +## Read product data + +```tsx +import { useProducts } from "superwall/hooks"; + +const { getProduct } = useProducts(); +const annual = getProduct("annual"); + +annual?.variables.price // "$59.99" — formatted for the user's region +annual?.variables.monthlyPrice // "$5.00" — the store's own math +annual?.variables.trialPeriodDays +``` + +References are typed against your config, so a typo in `getProduct("anual")` is a compile error, not a runtime surprise. + +Everything on `variables`, all optional: + +| Group | Variables | +| --- | --- | +| Price | `price`, `rawPrice`, `currencyCode`, `currencySymbol` | +| Period | `period` ("year"), `periodly` ("yearly"), `periodDays`, `periodWeeks`, `periodMonths`, `periodYears` | +| Per-interval price | `dailyPrice`, `weeklyPrice`, `monthlyPrice`, `yearlyPrice` | +| Trial | `trialPeriodDays`, `trialPeriodWeeks`, `trialPeriodMonths`, `trialPeriodYears`, `trialPeriodPrice`, `trialPeriodText` ("7-day"), `trialPeriodEndDate` ("Jul 23, 2026"), per-interval trial prices | +| Locale | `locale`, `languageCode` | +| State | `identifier`, `isSubscribed` | + +`period` and `periodly` arrive pre-localized to the device locale — "yearly" becomes "jährlich" on a German device, with no work on your side. + +## The three rules + +Three habits keep product data honest. + +### 1. Guard every read and design the unpriced state + +A declared reference always exists, but its variables may not have arrived yet — and in `superwall dev` they're `undefined` until the studio injects your dashboard's products. Degrade the copy; never invent a number: + +```tsx +{annual?.variables.price ? `Subscribe · ${annual.variables.price}` : "Subscribe"} +``` + +The unpriced state isn't an error state — your paywall will render it, so design it to read as intentional. + +### 2. `Number()` before arithmetic + +Numeric-looking variables arrive as **strings** on device (`"59.99"`, `"7"`). A `typeof x === "number"` check passes in dev and silently fails on a real phone — treating every product as trial-less: + +```tsx +const days = Number(annual?.variables.trialPeriodDays); +const trialDays = Number.isFinite(days) ? days : 0; +``` + +### 3. Display formatted, compute raw + +Use `price` and `monthlyPrice` for copy — they're formatted by the store for the user's region and currency. Use `rawPrice` when you need to compute or animate. Never derive a displayed price the store already provides: your division will disagree with the store's own math somewhere in the world. + +## Selection state is ordinary React + +The framework has no "selected plan" concept — selection is your state, typed against the config: + +```tsx +import { type ProductReference } from "superwall/hooks"; + +const [selected, setSelected] = React.useState("annual"); +``` + +The `product-selection` [example](/framework/examples) shows the full pattern: a typed plan union, `haptics.selection()` on choice, real `role="radiogroup"` semantics, and a designed unpriced state. + +## Create the products on the dashboard + +A push refuses if `config.ts` names a product the dashboard doesn't have. Create products in the dashboard, or straight from the CLI: + +```bash +superwall products create pro_5999_year \ + --name "Annual" --price 59.99 --period year \ + --trial-days 7 --entitlement +``` + +See the [CLI reference](/framework/cli) for the full flags. Once the products exist, continue to [Purchases](/framework/purchases). diff --git a/content/docs/framework/project-structure.mdx b/content/docs/framework/project-structure.mdx new file mode 100644 index 00000000..54cbf6cc --- /dev/null +++ b/content/docs/framework/project-structure.mdx @@ -0,0 +1,71 @@ +--- +title: "Project Structure" +description: "How a superwall/ directory is laid out, the two files the CLI manages, and the rules that keep a project portable." +--- + +Everything Superwall-related in your app lives in one `superwall/` directory — or the repo root, if you keep paywalls in a dedicated repo. It's a self-contained npm project: clone it, install, run `superwall dev`, and it works. Your host app needs no npm setup of its own. + +## Layout + +```ts +superwall/ +├── package.json depends on `superwall`, react, react-dom +├── tsconfig.json +├── superwall.d.ts generated — commit, never edit +├── superwall.lock dashboard bindings — commit +├── .gitignore +├── components/ components shared across paywalls +├── messages/ shared string catalogs (en.ts, de.ts, …) +├── assets/ shared images, video, fonts +├── paywalls// one directory per paywall +│ ├── config.ts required — definePaywall({ name, products }) +│ ├── app/ pages — index.tsx (required), layout.tsx, more pages +│ ├── components/ this paywall's own components +│ ├── messages/ this paywall's own strings +│ └── assets/ this paywall's own assets +└── funnels// same shape, for funnels +``` + +`components/`, `messages/`, and `assets/` work at both levels: shared at the root, local inside a paywall. `@/…` imports resolve from the `superwall/` root: + +```ts +import { Button } from "@/components/Button"; +``` + +## The rules + +A few conventions keep every project buildable, portable, and understandable at a glance: + +- **`app/` holds pages and nothing else.** Every `.tsx` file in `app/` is a page — lowercase-kebab filename, default-exported component. `layout.tsx` at the top level is the one reserved name; stylesheets may sit beside pages. Anything else belongs in `components/`. A stray file in `app/` is a warning in dev and blocks a push. +- **Every paywall starts at `app/index.tsx`** and must have a `config.ts`. +- **The directory name is the identifier.** It's the URL in dev and the dashboard binding on push — lowercase-kebab. The `name` in `config.ts` is only the human-readable label shown in the dashboard. +- **No build tooling.** No vite config, no `index.html`, no entry point — the framework owns the build end to end. +- **Never name the package `"superwall"`** in `package.json`. That would shadow the framework import. `superwall create` names it after your app. + +Commands work from your app root or from inside `superwall/` alike, and a globally installed `superwall` always defers to the project's own installed version — so everyone on the team builds with the version the project pins. + +## Two files the CLI manages — commit both + +### `superwall.d.ts` + +Regenerated on every `dev` and `push`. It's what makes `router.push("plans")` autocomplete and reject typos, gives `getProduct` and `purchase` their typed product references, and makes asset imports typecheck. Never edit it; never delete it. + +### `superwall.lock` + +Binds each paywall directory to its paywall on the dashboard, and records which Superwall app the project pushes to. Committing it is what makes every machine — and CI — push to the same paywalls. Nothing about the dashboard ever appears in `config.ts`; the lock file is the only place bindings live. + +Renaming a paywall directory is safe: the next `push` notices and asks whether it's a rename (keeping the live paywall attached) or a brand-new paywall. In CI, declare it with `--rename old=new`. See [Push, promote & publish](/framework/push-and-promote). + +## Keep imports inside the project + +Import from within `superwall/` or from packages listed in its `package.json`. An import that reaches outside — say `../../src/theme` — still builds on your machine, but the pushed source can no longer be rebuilt anywhere else, so the dashboard disables remote editing for that paywall and the push warns, naming each offender. + +Copy shared code into `superwall/components/` instead. Duplication here is deliberate: it's what keeps the project self-contained. + +## `.env` + +`superwall/.env` (with your app root's `.env` as a fallback) holds project credentials — `SUPERWALL_API_KEY` for CI pushes. It's gitignored and never leaves your machine: source pushes exclude `.env*`, `node_modules/`, `.superwall/`, and anything your `.gitignore` lists. + +## Funnels + +Multi-step flows — onboarding quizzes, web funnels — use exactly the same layout as paywalls and live under `superwall/funnels//`. A funnel is one surface whose steps are pages, not a chain of separate paywalls. See [Pages & navigation](/framework/navigation). diff --git a/content/docs/framework/purchases.mdx b/content/docs/framework/purchases.mdx new file mode 100644 index 00000000..b44ca78c --- /dev/null +++ b/content/docs/framework/purchases.mdx @@ -0,0 +1,101 @@ +--- +title: "Purchases" +description: "Make the sale with usePurchase — handle completed, abandoned, and failed outcomes, restore purchases, and react to transactions from anywhere." +--- + +A purchase is one call: pass a product reference, await the result, react to what happened. The SDK owns the store sheet, the payment, and the receipt. + +```tsx +import { usePurchase, useHaptics } from "superwall/hooks"; + +const { purchase } = usePurchase(); +const haptics = useHaptics(); + + +``` + +## The three outcomes + +`purchase()` resolves — it never throws for flow outcomes: + +| Status | Meaning | Respond by | +| --- | --- | --- | +| `completed` | The sale went through | `haptics.success()`; the SDK dismisses the paywall if configured | +| `abandoned` | The user closed the store sheet | Treat as an ordinary outcome — most people who open a sheet close it. This is the only place *this paywall's own* declined offer is visible: show a last-chance offer, or nothing | +| `failed` | No transaction happened — `reason` is `"timeout"` or `"superseded"` (a retry or re-presentation replaced this attempt) | Usually nothing; `haptics.error()` at most | + +### Never put the buy button in a loading state + +No "One moment…", no disabling, no spinner. The store sheet *is* the feedback, and the SDK owns when it appears. A button that visibly waits makes the paywall feel broken in the gap the platform already covers. + +### Abandoned is a signal, not a failure + +Someone opened the sheet and closed it — that's the closest thing a paywall gets to hearing "not at this price." A common pattern is pushing a last-chance offer: + +```tsx +const result = await purchase(selected); +if (result.status === "abandoned") { + router.push("offer", { transition: "sheet" }); +} +``` + +**One recovery offer, not two.** If the user abandons the discounted offer as well, let them be. The `abandonment-offer` [example](/framework/examples) shows the full pattern — a second product, not a second design. + +## Options + +```tsx +purchase(reference, { shouldDismiss?, timeoutMs? }) +``` + +Both default to what [`config.ts`](/framework/config) declares (`dismissOnPurchase`, `purchaseTimeoutMs`). + +## The two channels + +Your `purchase()` call is one channel. The SDK reporting on its own is the other — and it reports transactions **whoever started them**. A successful restore arrives as a `transaction_complete` event with no purchase call in sight; so does a purchase completed from a re-presented paywall. + +```tsx +// this paywall's own attempt +const result = await purchase("annual"); + +// anything the SDK reports — purchase, restore, trial start +useSuperwallEvent("transaction_complete", () => haptics.success()); +useSuperwallEvent("freeTrial_start", () => {}); +``` + +Drive *this paywall's* flow from the awaited result; use events for side effects that should fire on any transaction, however it started. The `purchase-states` [example](/framework/examples) shows both channels side by side — and it's the one example that demonstrates the full haptic vocabulary (`success()` and `error()` keyed to outcomes). + +See [Lifecycle & events](/framework/lifecycle) for the full event list. + +## Restore + +```tsx +import { useActions, useHaptics } from "superwall/hooks"; + +const { restore } = useActions(); + + +``` + +`restore()` is fire-and-forget — there is no result to await. Success surfaces as a `transaction_complete` event or a dismissed paywall; nothing surfaces on failure. Every store paywall should offer restore — App Review expects it. + +## Haptics on outcomes + +iOS fires no feedback of its own inside a paywall, so the vocabulary is yours to supply: + +- `haptics.light()` when the buy button is tapped +- `haptics.success()` when a transaction completes — via the event, so restores count too +- `haptics.error()` sparingly, on `failed` + +## Selling beyond the App Store + +Trials — who's eligible, what to show each side — have their own page: [Free trials](/framework/trials). And a single config key sells the same paywall on the web through Stripe, with `purchase()` unchanged: [Web checkout](/framework/web-checkout). diff --git a/content/docs/framework/push-and-promote.mdx b/content/docs/framework/push-and-promote.mdx new file mode 100644 index 00000000..2e959fda --- /dev/null +++ b/content/docs/framework/push-and-promote.mdx @@ -0,0 +1,97 @@ +--- +title: "Push, Promote & Publish" +description: "Ship paywalls with git semantics: push seals an immutable version, promote points production at it, publish does both." +--- + +Shipping has git semantics on purpose: **push saves, promote ships.** Every push mints a sealed, immutable version; nothing your users see changes until promote points production at it. + +```bash +superwall push # build + version. Production untouched. +superwall promote # point production at the latest push +superwall publish # push + promote in one step +superwall publish -m "Q3 test" # record why +``` + +The scaffolded project mirrors these as package scripts (`dev`, `push`, `promote`, `ship`). + + +Pushing requires the **headless paywalls** feature to be enabled on your Superwall application — it's a server-side flag, so if a push says it isn't enabled, the account owner needs to have it turned on. + + +## `superwall push` + +Builds every paywall, versions the changed ones, and leaves production alone. Re-running with nothing changed is a no-op. + +| Flag | What it does | +| --- | --- | +| `--id ` | Limit the push to one paywall (repeatable) | +| `--rename =` | Declare a directory rename (see below) | +| `-m ` | Record why this version exists | + +The **first push binds** each paywall — creating it on Superwall if needed — and records the binding in `superwall.lock`. Commit that file: it's what makes every machine and CI push to the same paywalls. After that, push always updates the same paywall; no IDs ever appear in your code. + +A push refuses — before anything is written — when: + +- **A selected paywall has diagnostics.** Publishing is immutable; fix the named problems first. They're the same warnings `superwall dev` prints. +- **A product in `config.ts` doesn't exist on the dashboard.** Every variable on it would be undefined on device. Create the products first — see [Products](/framework/products) and the [CLI reference](/framework/cli). +- **A directory rename is unresolved** (below). + +## Renames + +Renaming a paywall directory is detected, never guessed. Interactively, push asks: + +``` +? `pro-upgrade` is not in superwall.lock. Is it a new paywall, or renamed? + › Renamed from plus-upgrade paywall 208540 + Create a new paywall +``` + +Choosing the rename keeps the live paywall attached to the new directory. In CI there's no one to ask, so declare it — anything unresolved stops the push rather than silently creating a duplicate: + +```bash +superwall push --rename plus-upgrade=pro-upgrade +``` + +Deleting a paywall directory never blocks a push: the dashboard paywall keeps serving, and restoring the directory re-binds it. + +## Source snapshots + +Every push also snapshots your `superwall/` source to Superwall, so the dashboard can show — and diff — the exact code each version was built from. The `-m "why"` note is recorded there too. + +What never leaves your machine: `.env` files, `node_modules/`, `.superwall/`, and anything your `.gitignore` lists. + + +If any import reaches outside the project directory, the push warns naming each offender, and the dashboard disables remote editing for that paywall — the pushed source can't be rebuilt elsewhere. Copy shared code into `superwall/components/` instead. See [Project structure](/framework/project-structure). + + +## `superwall promote` + +Points production at a pushed version. Promote never rebuilds — it only moves the live pointer, so it's instant, and rollback is the same move in reverse: + +```bash +superwall promote # latest push, every paywall +superwall promote --id plus-upgrade # just one +superwall promote --id plus-upgrade --version 5 +# → Rolled back version 7 → 5 +``` + +`--version`/`-v` (with a single `--id`) picks a specific version — pinning forward or rolling back are the same operation. + +## `superwall publish` + +Push + promote in one step. It also warns about other paywalls that are pushed-but-not-live, so nothing ships half-forgotten. + +`publish` requires git — the source snapshot is part of every publish. + +## CI + +Interactive machines authenticate once with `superwall login`. In CI, set `SUPERWALL_API_KEY` (an `sk_…` key) in the environment — `superwall/.env` works locally and is gitignored. `dev` needs no login at all. + +A typical CI ship step: + +```bash +superwall push --rename old=new -m "$COMMIT_MESSAGE" # renames declared, reason recorded +superwall promote +``` + +Because `superwall.lock` is committed, CI pushes to exactly the same paywalls as every developer machine. diff --git a/content/docs/framework/quickstart.mdx b/content/docs/framework/quickstart.mdx new file mode 100644 index 00000000..0152ffdd --- /dev/null +++ b/content/docs/framework/quickstart.mdx @@ -0,0 +1,146 @@ +--- +title: "Quickstart" +description: "Scaffold a Superwall Framework project, preview your first paywall in the studio, and ship it to production." +--- + +This guide takes you from nothing to a live paywall: scaffold a project inside your app, preview it locally, and push it to Superwall. + +## Before you start + +You'll need: + +- **Node 20+** (or Bun) and **git**. +- A **Superwall account** with an application. The application must have the **headless paywalls** feature enabled — a push will tell you if it isn't. +- The **Superwall CLI**: + + + +```bash bun +bun add -g superwall +``` + +```bash npm +npm install -g superwall +``` + + + + + + +From the root of your app's repo: + +```bash +superwall create +``` + +This scaffolds a self-contained `superwall/` directory — its own `package.json`, a starter paywall, and everything wired up — then connects it to your Superwall app and installs dependencies. Your app itself needs no npm setup. + +To start from a working pattern instead, scaffold any [example](/framework/examples) — each is a complete project: + +```bash +superwall create --example multi-page +``` + + + + +```bash +superwall dev +``` + +This opens the studio at `http://localhost:6100`: every paywall as a card with a live preview, and an editor per paywall with a device-frame view at exact logical size. Switch devices, toggle light and dark, rotate, change locales, and simulate purchases — the studio asks *you* to pick each outcome, so you can test every branch of your flow. See [The studio](/framework/studio) for the full tour. + +Edits hot-reload as you save. Warnings about project problems (a stray file in `app/`, a duplicate route) appear here too — they're the same checks that block a push, so fix them as they come up. + + + + +Open `superwall/paywalls//` and edit. A paywall is ordinary React: + +```tsx +// app/index.tsx +import { useProducts, usePurchase, useActions, useHaptics } from "superwall/hooks"; + +export default function Paywall() { + const { getProduct } = useProducts(); + const { purchase } = usePurchase(); + const { close } = useActions(); + const haptics = useHaptics(); + const annual = getProduct("annual"); + + return ( +
+ +

Go Pro

+ +
+ ); +} +``` + +Two habits worth forming on day one: + +- **Guard every product read.** Prices arrive from the store at runtime; in dev they're `undefined` until the studio injects your dashboard's products. Degrade the copy — never invent a number. See [Products](/framework/products). +- **Fire a haptic on every meaningful tap.** iOS gives no feedback of its own inside a paywall. See [Styling & mobile design](/framework/styling). + +
+ + +`config.ts` declares product **slots** — the key is the name your code uses, the value is the store identifier: + +```ts +import { definePaywall } from "superwall/config"; + +export default definePaywall({ + name: "Pro — Annual", + products: { + annual: "pro_5999_year", + }, +}); +``` + +The identifiers must exist as products on your Superwall dashboard — a push refuses otherwise. Create them in the dashboard, or from the CLI with `superwall products create`. See [Products](/framework/products). + + + + +```bash +superwall push # build + seal an immutable version — production untouched +superwall promote # point production at the latest push +``` + +Push saves, promote ships — the same split as git push and a deploy. `superwall publish` does both in one step. The first push binds each paywall to your dashboard and records the binding in `superwall.lock`; commit that file so every machine and CI push to the same paywalls. See [Push, promote & publish](/framework/push-and-promote). + + + + +Nothing changes on the app side: add the paywall to a campaign in the dashboard, and your existing `register` / placement calls present it. If you're new to Superwall, follow your platform's quickstart — [iOS](/ios), [Android](/android), [Expo](/expo), or [Flutter](/flutter) — to get the SDK configured and a placement registered. + + +
+ +## Where to next + + + + The full directory layout, the two generated files, and what to commit. + + + Turn one page into a multi-step flow. + + + Handle completed, abandoned, and failed — and why the buy button never shows a spinner. + + + Complete projects for product selection, onboarding quizzes, trials, and more. + + diff --git a/content/docs/framework/studio.mdx b/content/docs/framework/studio.mdx new file mode 100644 index 00000000..afca5405 --- /dev/null +++ b/content/docs/framework/studio.mdx @@ -0,0 +1,52 @@ +--- +title: "The Studio" +description: "Preview every paywall locally with superwall dev — devices, themes, locales, live variables, and simulated purchases." +--- + +`superwall dev` hosts the studio at `http://localhost:6100`: every paywall in your project as a card with a live miniature, and an editor per paywall with a device-frame preview at exact logical size. It's where you check everything you can't check in code. + +```bash +superwall dev # the current project +superwall dev examples/* # several projects at once +``` + +`dev` needs no login, regenerates `superwall.d.ts` first (so route and product types are always current), and takes `--port`/`-p` (default 6100, moving to the next free port) and `--host`. + + +Project problems — a stray file in `app/`, a duplicate route — print as warnings in dev. They're the same checks that block a push, so fix them as they appear rather than discovering them at ship time. + + +## What you can check + +- **Devices** — iPhone SE through iPad Pro, plus Pixel. Switching devices also changes what the paywall sees as platform, model, and OS version, so platform-conditional code is testable too. +- **Light and dark** — the studio's theme toggle drives the same `dark` class the SDK stamps on device. Check both, always. +- **Locale** — switch languages to proof every catalog. See [Localization](/framework/localization). +- **Rotation** — portrait and landscape, live. See `useDevice().orientation` in the [hooks reference](/framework/hooks). +- **Trial eligibility** — a toggle that flips the store's answer, so both versions of a trial paywall are one click apart. See [Free trials](/framework/trials). +- **Variables** — edit user attributes, device properties, placement params, and per-product variables live in the Variables panel. Values are seeded from your app's real sample data and products, so the preview reflects what production will see. See [Variables & personalization](/framework/variables). + +## Simulated outcomes + +In dev, everything that would normally resolve from the host — purchases, restores, permission prompts, callbacks — prompts **you** to pick the outcome instead, so both branches of every flow are testable. Decline your own purchase to check the abandoned path; deny your own permission request to check the fallback copy. + +Alongside it runs the **event log**: every message the paywall sends — haptics, page views, purchase attempts — as it happens. It's where you confirm that a tap fired its haptic, or that an action reached the host. + +## Dev vs device + +The same paywall runs against a simulated host in dev and the real SDK on device. What differs: + +| | `superwall dev` | Real device | +| --- | --- | --- | +| Product variables | `undefined` until the studio injects your dashboard products | Delivered by the SDK | +| `purchase()` / `restore()` | Simulated — you pick the outcome | Real store | +| `close()`, `openUrl()`, haptics | Logged in the event log | Acted on by the host | +| Permissions / callbacks | Studio prompts you | OS prompt / your app's code | +| Numeric variables | Numbers | **Strings** — always `Number()` first | +| Presentation (`paywall_open`) | Immediate | After preload, when actually shown | +| Web checkout sheet | Not mounted — verify on a pushed version | Works | + +A published paywall never falls back to simulated data — the simulation exists only in previews. + +## The Push, Publish, and Promote buttons + +The studio has buttons for the same operations as the CLI — good for quick iteration. For actually shipping, prefer the CLI: the buttons skip the diagnostics gate and the dashboard product check, can't resolve renames, and take no `-m` note. See [Push, promote & publish](/framework/push-and-promote). diff --git a/content/docs/framework/styling.mdx b/content/docs/framework/styling.mdx new file mode 100644 index 00000000..29aa7eb0 --- /dev/null +++ b/content/docs/framework/styling.mdx @@ -0,0 +1,84 @@ +--- +title: "Styling & Mobile Design" +description: "Dark mode, safe areas, scroll behavior, motion, and touch — the platform conventions that make a paywall feel native inside a webview." +--- + +Paywalls render inside a native webview on a phone. Two things decide whether one feels native: reproducing your design exactly, and following the platform conventions — Apple's HIG and their Android equivalents — that users feel but never name. This page collects the conventions; treat them as working practices, with your design reference always winning over any rule here. + +## The design is the contract + +- **Build 1:1.** Spacing, sizing, weights, colors, and effects come from the design, not from habit. Measure the design at logical points — a screenshot at device width — instead of eyeballing, and compare your build against it side by side before calling it done. +- **Add nothing the design doesn't show.** No extra links, badges, footnotes, or affordances, however well-intentioned. If something seems missing — a restore button, a legal link — raise it with your designer rather than quietly adding it. +- **Effects are design decisions, not defaults.** Shadows, gradients, borders, blurs, and radii belong to the design system of the paywall you're building. If the design is flat, build flat; if it's soft and elevated, match that. + +## Dark mode + +The device decides, and the framework maintains a `dark`/`light` class on ``. Style with plain CSS and write no wiring: + +```css +:root { --bg: #fdfef6; --fg: #0c0b0a; } +:root.dark { --bg: #1c1b19; --fg: #fdfef6; } +``` + + +Don't use `@media (prefers-color-scheme: dark)` as the mechanism. The media query can't see what the device reports through the SDK and doesn't respond to the studio's theme toggle — a paywall styled that way looks right on your machine and wrong on the device. The `:root.dark` class is the mechanism. + + +Using Tailwind? Redefine the `dark:` variant onto the class so it follows the SDK instead of the media query: + +```css +@custom-variant dark (&:where(.dark, .dark *)); +``` + +The Tailwind example shows the full setup — see [Examples](/framework/examples). Design both palettes even when the reference shows only one, and check both in the studio. + +## Safe areas + +`env(safe-area-inset-*)` resolves to **0** in previews and some webview contexts, so bare `env()` math puts controls in the status bar or under the home indicator the moment insets go missing. Always wrap in `max()` with a floor: + +```css +/* fixed top chrome (close button): clears the status bar even with no env */ +top: max(calc(env(safe-area-inset-top, 0px) + 10px), 60px); + +/* pinned bottom chrome: clears the home indicator */ +padding-bottom: max(calc(env(safe-area-inset-bottom, 0px) + 14px), 28px); +``` + +Around 60px is a sensible top floor and 28px a bottom floor — adjust the numbers to your design, keep the pattern. Fixed elements (close button, CTA bar) need the inset math; scrolling content instead needs enough bottom padding to clear whatever is pinned over it. + +## Scrollable content + +- Long content scrolls **under** pinned bottom chrome. Give the pinned footer a gradient — transparent to page background — so content fades out behind it instead of clipping to a hard edge. +- Put `pointer-events: none` on the pinned container and `pointer-events: auto` back on its interactive children, so the fade region doesn't swallow scroll gestures. +- Give the scroll content bottom padding of roughly the footer height plus the safe area, so the last row can scroll clear of the fade. +- Let the page itself scroll; don't invent nested scroll areas. The platform — and `scrollEnabled` in [config](/framework/config) — owns scroll behavior. + +## Motion + +- **Animate functional movement only** — elements that physically travel between states: a segmented-control thumb sliding, a sheet presenting, a progress bar filling. Content that merely changes — text, list rows, a price — updates in place; it doesn't fade, slide, or stagger unless the design explicitly calls for it. +- **Press feedback is the baseline interaction**: a scale-down active state (around 0.96, fast in at ~80ms, settling out at ~200ms) on tappable elements, paired with a haptic. For most controls, that's the whole story. +- **Entry animations are opt-in per design** — and when a design has one, it gates on presentation, never mount, because paywalls are preloaded hidden. See [Lifecycle & events](/framework/lifecycle). +- Honor `prefers-reduced-motion` by collapsing durations to ~1ms. + +## Touch + +- **Tap targets are at least 44×44pt.** A visually shorter control — a slim segmented control — can trade height when the design demands it, but width and spacing must compensate. +- **Haptics on every meaningful tap**, via [`useHaptics()`](/framework/hooks#usehaptics): `light` for navigation and CTAs, `selection` for choosing between options, `success` when a purchase lands, `error` sparingly on failures. iOS fires nothing on its own inside a webview. +- **Suppress focus rings on tap-driven controls.** The `:focus-visible` heuristics misfire in webviews and previews, drawing outlines the design never asked for. Keep keyboard focus styles only where a keyboard is real, like web checkout pages. +- On controls: `-webkit-tap-highlight-color: transparent`, `touch-action: manipulation`, `user-select: none`. +- Icon-only buttons carry an `aria-label`; every control stays reachable. + +## Type and rendering + +- Default to the system font stack — `-apple-system, BlinkMacSystemFont, …` — unless the design specifies brand type. It's what makes a webview read as native iOS. (When the design calls for brand type, see [custom fonts in Assets](/framework/assets).) +- Set `-webkit-text-size-adjust: 100%` on `html`, use antialiased smoothing, and keep body copy around 17px to match iOS body text. + +## Verify like a device + +In the [studio](/framework/studio), before calling any paywall done: + +- Both color schemes. +- The smallest supported width — 320px — through tablet. +- Every page in the flow. +- The trial-eligibility toggle, where relevant. +- Nothing overflows horizontally at any size. diff --git a/content/docs/framework/transitions.mdx b/content/docs/framework/transitions.mdx new file mode 100644 index 00000000..cf1aed97 --- /dev/null +++ b/content/docs/framework/transitions.mdx @@ -0,0 +1,103 @@ +--- +title: "Transitions" +description: "Built-in page transitions, where to set them, and how to define your own with nothing but a name and CSS." +--- + +Every navigation animates. The framework ships four built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases — no registration, no JavaScript. + +## Built-ins + +- **`push`** — iOS-style: the new page slides in from the right while the one behind recedes. The default. +- **`slide`** — the new page slides over the current one. +- **`fade`** — a crossfade, one layer at a time. +- **`none`** — instant. + +## Set them at three levels + +The call site wins, then the page, then the surface: + +```tsx +router.push("plans", { transition: "none" }); // one navigation +export const transition = "fade"; // one page (top of its file) +export default definePaywall({ transition: "slide" }); // whole surface +``` + +Going forward uses the *incoming* page's transition; going back uses the *leaving* one's — so a page always leaves the way it arrived. + +## Tune the built-ins + +Three CSS variables adjust timing and feel without replacing anything: + +```css +:root { + --sw-transition: 500ms; /* duration */ + --sw-ease: cubic-bezier(0.28, 0.4, 0.08, 1); + --sw-stack-dim: 0.9; /* how much the page behind dims */ +} +``` + +All motion respects `prefers-reduced-motion` automatically — with reduced motion on, the router settles instantly. + +## Custom transitions + +A transition is just a name plus CSS. Name it anywhere a transition goes, then style the four phases: + +```tsx +export const transition = "zoom"; +``` + +```css +@media (prefers-reduced-motion: no-preference) { + [data-sw-transition="zoom"][data-sw-phase] { + animation-duration: 420ms; + animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); + } + [data-sw-transition="zoom"][data-sw-phase="enter"] { animation-name: zoom-enter } + [data-sw-transition="zoom"][data-sw-phase="recede"] { animation-name: zoom-recede } + [data-sw-transition="zoom"][data-sw-phase="leave"] { animation-name: zoom-leave } + [data-sw-transition="zoom"][data-sw-phase="return"] { animation-name: zoom-return } +} + +@keyframes zoom-enter { from { transform: var(--sw-from-transform, scale(0.85)); opacity: 0 } } +@keyframes zoom-recede { to { opacity: 0; transform: scale(1.15) } } +@keyframes zoom-leave { to { opacity: 0; transform: scale(0.85) } } +@keyframes zoom-return { from { opacity: 0; transform: scale(1.15) } } +``` + +The four phases cover both directions of travel: + +| Phase | The page is… | +| --- | --- | +| `enter` | arriving on top | +| `recede` | being covered as you go forward | +| `leave` | dropping off the top as you go back | +| `return` | coming forward again as you go back | + +### Rules for custom transitions + +- **Always start `from` at `var(--sw-from-transform, )`** — and `--sw-from-filter` for filters. The router fills these with a page's live position when a navigation interrupts an animation, so a spammed button picks the page up where it stands instead of snapping. +- **Wrap in `prefers-reduced-motion: no-preference`.** With reduced motion on, the router settles instantly and your animation never runs. +- **Duration comes from your CSS.** The page stays mounted exactly as long as its animation runs; don't declare a duration anywhere else. +- **Omit phases you don't want.** They don't animate — that's how `fade` crossfades one layer at a time. + +## Bottom sheets over the flow + +For a modal-feeling page — a last-chance offer, say — define a `sheet` transition: the page slides up while the one behind scales back and dims. Darken the container behind it in the same motion by reusing the framework's timing variables: + +```css +:root { --dim: 0.85; } + +[data-sw-routes] { + transition: background-color var(--sw-transition, 500ms) + var(--sw-ease, cubic-bezier(0.28, 0.4, 0.08, 1)); +} +[data-sw-routes]:has([data-sw-transition="sheet"][data-sw-phase]) { + background-color: color-mix(in srgb, var(--bg) calc(var(--dim) * 100%), #000); +} +``` + +One `--dim` number drives both the page's `brightness()` and the backdrop, so they always match. Dismissing the sheet is `router.back()` — the page leaves the way it came. The abandonment offer example has the full recipe — see [Examples](/framework/examples). + +## Transitions vs. in-page animation + +Animation libraries (Motion, plain CSS) animate *inside* a page. Moving *between* pages stays the router's job. Keep that line and spamming navigation can never fight your component animations — and remember that entry animations gate on presentation, never mount. See [Lifecycle & events](/framework/lifecycle). diff --git a/content/docs/framework/trials.mdx b/content/docs/framework/trials.mdx new file mode 100644 index 00000000..d272d40b --- /dev/null +++ b/content/docs/framework/trials.mdx @@ -0,0 +1,90 @@ +--- +title: "Free Trials" +description: "Fork your paywall on trial eligibility the store reports, pull trial terms from product variables, and remind users before a trial ends." +--- + +The store decides who gets a trial — not you, and not the user's claim. Someone who used their trial two years ago and reinstalled is ineligible, and only the store knows. `useTrialEligibility()` is that signal, and it splits your paywall into two versions that must **both** read as intentional. + +## Read eligibility + +```tsx +import { useTrialEligibility } from "superwall/hooks"; + +const { eligible } = useTrialEligibility(); // boolean | undefined +``` + +`eligible` is `undefined` until the SDK reports, so gate trial-only UI on `eligible === true` — never on "not false." + +## Two paywalls in one + +Fork every user-facing string, including the CTA. A returning customer sees the ineligible copy, and it cannot read like a mistake: + +```tsx +const { eligible } = useTrialEligibility(); +const days = annual?.variables.trialPeriodDays; + +

{eligible ? "Start free" : "Go Pro"}

+

+ {eligible + ? days + ? `${days} days free, then ${annual?.variables.price ?? "the annual price"}.` + : "Your trial is on the house." + : "You have used your trial. Subscribe to keep going."} +

+ +``` + +Two details in that snippet are deliberate: + +- **The fallbacks nest.** Eligible-but-days-unknown gets its own sentence — the data may not have arrived yet, and "undefined days free" is never acceptable copy. +- **The ineligible side is written, not defaulted.** "You have used your trial" tells a returning customer the paywall knows who they are. + +## Trial terms come from the product + +Trial length, price, and end date are variables on the product — `trialPeriodDays`, `trialPeriodPrice`, `trialPeriodEndDate`, `trialPeriodText` — never values in your files. They follow the same rules as every product read: guard them, and `Number()` before arithmetic. See [Products](/framework/products). + +## Test both sides + +- **The studio** has a trial-eligibility toggle — flip it and check every string on both sides. See [The studio](/framework/studio). +- **Config can force either side** while you're building: + +```ts +introductoryOfferEligibility: "alwaysEligible" | "alwaysIneligible" // default "automatic" +``` + +Leave it on `"automatic"` for production — that lets the store decide. + +The `trial-eligibility` [example](/framework/examples) is the reference: every string forks, and both states read as designed. + +## Trial reminder notifications + +Declare a local notification in `config.ts` and the SDK schedules it when a trial **actually starts** — the paywall doesn't need to be open when it fires: + +```ts +notifications: { + trialReminder: { + title: "Your trial ends tomorrow", + body: "Keep Pro, or cancel in Settings — no charge either way.", + beforeTrialEndDays: 1, // default 1 + }, +}, +``` + +`title`, `subtitle`, and `body` accept message keys (resolved through `t()` — see [Localization](/framework/localization)) or literal copy. + +For full control, pass a function instead. It receives `{ trialEndDate, product, t, locale }` and returns `{ title, body, delayMs }` — or `null` to skip the notification entirely: + +```ts +notifications: { + trialReminder: ({ trialEndDate, t }) => + trialEndDate + ? { title: t("reminder.title"), body: t("reminder.body"), delayMs: 0 } + : null, +}, +``` + +The `trial-reminders` [example](/framework/examples) shows both forms. + + +Users warned before the charge cancel calmly instead of charging back — and the ones who stay chose to stay. + diff --git a/content/docs/framework/troubleshooting.mdx b/content/docs/framework/troubleshooting.mdx new file mode 100644 index 00000000..8c36cf53 --- /dev/null +++ b/content/docs/framework/troubleshooting.mdx @@ -0,0 +1,82 @@ +--- +title: "Troubleshooting" +description: "Common CLI errors and runtime surprises — what each one means and how to fix it." +--- + +The most common failures, in two groups: errors the CLI prints, and runtime behavior that surprises people the first time. + +## CLI errors + +### `Not a superwall project` + +The CLI couldn't find a project from where you ran it. Run commands from your app root or from inside `superwall/` — and check that the project's `package.json` depends on `superwall`. See [Project structure](/framework/project-structure). + +### `…package.json is named "superwall"` + +Your project's `package.json` has `"name": "superwall"`, which shadows the framework import — nothing in the project can `import` from `superwall` anymore. Rename the package; `superwall create` names it after your app for exactly this reason. + +### `No superwall framework found` + +The project exists but its dependencies aren't installed, or `superwall` isn't among them. Run `bun add superwall` (or `npm install superwall`) inside the project directory. + +### `These N products do not exist on Superwall` + +A `config.ts` names a product identifier the dashboard has no product for. The push refuses because every variable on that product would be undefined on device. Either fix the identifier, or create the products — from the dashboard, or with `superwall products create` from the CLI. See [Products](/framework/products) and the [CLI reference](/framework/cli). + +### `Headless paywalls are not enabled for this application` + +The framework requires the headless paywalls feature on your Superwall application. It's a server-side flag — nothing in the CLI can set it. The account owner needs to have it enabled; contact us if it isn't. + +### `Multiple projects found. Pass --project .` + +Your account has several Superwall projects, and the command can't guess which one you mean. Add `--project ` (and usually `--app `) to the command. + +### Diagnostics block the push + +Publishing is immutable, so a paywall with diagnostics — a stray non-page file in `app/`, a duplicate route — refuses to push. The message names each file and where it belongs. These are the same warnings `superwall dev` prints, so you'll usually have seen them before push time. + +### Rename ambiguity in CI + +A renamed paywall directory can't be resolved interactively in CI, so the push stops rather than creating a duplicate. Add the `--rename old=new` flag the error prints. See [Push, promote & publish](/framework/push-and-promote). + +### `paywall x has never been pushed` (promote) + +Promote only moves the live pointer between pushed versions — there's nothing to point at yet. Push first. + +### `superwall publish requires git` + +The source snapshot is part of every publish. Install git. + +### Not signed in + +Run `superwall login` once interactively, or set `SUPERWALL_API_KEY` (an `sk_…` key) in CI. `superwall dev` needs no login. + +## Runtime surprises + +### Prices are undefined in dev + +Expected. In `superwall dev`, product variables are `undefined` until the studio injects your dashboard's products — which is why every read is guarded and the unpriced state is designed, not accidental. The reading rules are in [Products](/framework/products). + +### A number comparison works in dev but not on device + +Numeric-looking variables are numbers in dev but **strings on a real device** (`"59.99"`, `"7"`). A `typeof x === "number"` check silently fails on every phone. Coerce with `Number()` before arithmetic or comparison ([Products](/framework/products)). + +### My entry animation already finished when the paywall appears + +The SDK preloads paywalls hidden, so components mount long before anyone is looking — a mount-timed animation plays to an empty room. Gate entry animations on presentation, not mount. See [Lifecycle & events](/framework/lifecycle). + +### Dark mode looks right on my machine, wrong on device + +The mechanism is the `dark` class the framework maintains on `` — not `prefers-color-scheme`. A media query can't see what the device reports and ignores the studio's theme toggle. Style off the class, as shown in [Styling & mobile design](/framework/styling). + +### My link does nothing + +Inside a webview, an `
` either does nothing or navigates the paywall away from itself. Open links through `useActions().openUrl` instead. See [Actions](/framework/actions). + +### Controls sit in the status bar / under the home indicator + +`env(safe-area-inset-*)` resolves to 0 in previews and some webview contexts, so bare `env()` math collapses. Always wrap in `max()` with a floor. See [Styling & mobile design](/framework/styling). + +### The payment sheet doesn't open in dev + +By design — `superwall dev` previews the flow and copy but doesn't mount the web checkout payment sheet. Push and open the live URL to verify the checkout itself. See [Web checkout](/framework/web-checkout). diff --git a/content/docs/framework/variables.mdx b/content/docs/framework/variables.mdx new file mode 100644 index 00000000..b8c9b196 --- /dev/null +++ b/content/docs/framework/variables.mdx @@ -0,0 +1,74 @@ +--- +title: "Variables & Personalization" +description: "React to user attributes, device state, and placement parameters — and write paywalls the dashboard can experiment on without a rebuild." +--- + +Everything your app and the SDK tell a paywall about the presentation arrives through `useVariables()`: who the user is, what device they're on, and what the placement was called with. Read these defensively and a single paywall can greet a returning user by name, adapt to platform, or react to any parameter your app passes — all without a rebuild. + +## `useVariables()` + +```tsx +import { useVariables } from "superwall/hooks"; + +const { device, user, params } = useVariables(); +``` + +Three records, three sources: + +- **`device`** — filled in by the SDK: `platform`, `deviceModel`, `osVersion`, `appVersion`, `deviceLocale`, `regionCode`, `deviceCurrencyCode`, `subscriptionStatus`, `activeEntitlements`, `daysSinceInstall`, `totalPaywallViews`, and more. +- **`user`** — whatever your app set via `setUserAttributes` (`user.firstName`, `user.plan`, …). +- **`params`** — whatever the placement was called with (`params.placementName`, plus anything the app passed alongside it). + +```tsx +const name = typeof user.firstName === "string" ? user.firstName : undefined; + +

{name ? `Welcome back, ${name}` : "Go Pro"}

+{device.platform ?? "—"} +``` + +## Guard every read + +All three records are filled in by the host — your paywall controls none of them, so every read needs a fallback: + +- For **`device`** fields, `?? "—"` (or any sensible default) suffices — the SDK guarantees the shape, just not that a value has arrived yet. +- For **`user`** and **`params`**, the host controls the *type* too, so check it before using it: `typeof params.placementName === "string"`. An attribute your app sets as a number today might be a string tomorrow, and the paywall must not crash either way. + + +`device.isSandbox` is a string, not a boolean. Compare it as one. + + +While previewing, every one of these values is editable live in the studio's **Variables** panel — user attributes, device properties, placement params, and per-product variables — seeded from your app's real sample data. Change a value and watch the paywall react. See [The studio](/framework/studio). + +## `useUser()` + +Shorthand for when you only need the user record: + +```tsx +import { useUser } from "superwall/hooks"; + +const user = useUser(); +``` + +Identical to `useVariables().user` — reach for it when the device and params records aren't needed. + +## `useDevice()` + +The same device record as `useVariables().device`, plus **`orientation`**: + +```tsx +import { useDevice } from "superwall/hooks"; + +const { orientation, platform, deviceModel } = useDevice(); +``` + +`orientation` is `"portrait" | "landscape"`, measured in the page itself — it updates the moment the device turns, so you can build layouts that answer to rotation. The orientation example reflows to a two-column grid in landscape rather than shrinking the portrait layout; see [Examples](/framework/examples). + +## Built to be experimented on + +Notice what's missing: variables are never *declared* in code. What the paywall reads — user attributes, device state, placement params, product variables, trial eligibility — is supplied by the app and the store at runtime, and the studio overrides all of it live while previewing. + +Write every read defensively — guarded, typed, with a designed fallback — and every one of those values becomes a knob the dashboard can turn without a rebuild. A paywall that renders sensibly for any combination of inputs can be A/B tested freely. + + +The personalization example shows the full doctrine in one project: `?? "—"` for SDK-guaranteed device fields, `typeof` checks for host-controlled user and params reads, and designed fallbacks for every string. See [Examples](/framework/examples). + diff --git a/content/docs/framework/web-checkout.mdx b/content/docs/framework/web-checkout.mdx new file mode 100644 index 00000000..d722416f --- /dev/null +++ b/content/docs/framework/web-checkout.mdx @@ -0,0 +1,88 @@ +--- +title: "Web Checkout" +description: "Sell the same paywall on the web with one config key — Stripe payment in a sheet, Apple Pay, or a hosted checkout page, with purchase() unchanged." +--- + +One config key sells the same paywall on the web: + +```ts +checkout: "sheet", +``` + +Native hosts ignore it — drop the same paywall into your iOS app and it buys through the App Store. Your components don't change, and neither does `purchase()`. + + +This page covers the framework side: config, modes, and prefetching. Stripe keys, web apps, products, and campaigns are set up in the dashboard — see the [Web Checkout](/web-checkout) section for that half. + + +## Modes + +| Mode | The purchase | Use when | +| --- | --- | --- | +| `sheet` | Stripe checkout in a sheet **over the paywall** — nobody leaves mid-flow | The default choice for the web | +| `applePay` | Straight to Apple Pay where available, sheet as fallback | Apple-Pay-heavy audiences | +| `external` | Superwall's hosted checkout page, then back | You want zero payment UI in the paywall | + +Only `sheet` and `applePay` add payment UI to the paywall (about 85 kB); `external` adds nothing. + +## Products + +Web paywalls sell Stripe products, declared with the price inside the identifier — `{environment}:{priceId}:{offer}`: + +```ts +products: { + monthly: "live:price_1ABC…:7days-free", +}, +``` + +A paywall can declare store and Stripe products side by side. See [Products](/framework/products). + +## The purchase, unchanged + +With `sheet` or `applePay` and a Stripe product, the same `purchase()` call opens the payment sheet in-page — a brief loading overlay covers the session creation unless it was prefetched. The outcomes map exactly as they do natively: + +- `completed` — payment succeeded +- `abandoned` — the shopper closed the sheet +- `failed` — a payment or session error + + +The web sheet does not set `isPurchasing` — react to the awaited result, which is the right pattern everywhere anyway. A web paywall also typically drops the close button and restore link its native sibling carries: there's no host app to close back to. + + +## Prefetch — make the sheet open instantly + +Creating a checkout session takes a network round-trip. Prefetching does it before the tap, so the sheet opens with nothing to wait for. + +**Automatic:** every `sheet`/`applePay` paywall warms its first Stripe product on load. Steer it in config: + +```ts +checkout: { mode: "sheet", prefetch: "pro" } // which product warms first +checkout: { mode: "sheet", prefetch: false } // disable auto-prefetch +``` + +**On selection — do this whenever there's a product selector.** The default warms one plan; prefetch the selected one so whichever plan is on screen opens instantly: + +```tsx +import { usePurchase, type ProductReference } from "superwall/hooks"; + +const { purchase, prefetch } = usePurchase(); +const [reference, setReference] = React.useState("monthly"); + +React.useEffect(() => { + prefetch(reference); +}, [prefetch, reference]); +``` + +`prefetch` is safe to call unconditionally — it's a no-op for store products, for paywalls without web checkout, and for already-warm sessions (sessions stay warm for about ten minutes). It's a hint; never await it. + +## The sheet is not yours to style + +It takes no colors, fonts, or spacing from the page around it, and there's no prop to change that. This is deliberate: payment UI that borrows the paywall's design stops looking like payment UI — and the payment step is the one place a shopper is entitled to see something they recognize. Safe areas, scroll locking, and Escape handling (never mid-payment) are handled for you. + +## Verify on a pushed version + +`superwall dev` previews the flow and the copy, but it does not mount the payment sheet. Push and open the live URL to verify the checkout itself — see [Push, promote & publish](/framework/push-and-promote). + +## A full web funnel + +The `web-funnel` [example](/framework/examples) is the reference: question steps as pages, a typed plan selector with on-selection prefetch, then `purchase(reference)` — the whole flow in one paywall. diff --git a/content/docs/meta.json b/content/docs/meta.json index 6a470cff..79c9649c 100644 --- a/content/docs/meta.json +++ b/content/docs/meta.json @@ -6,6 +6,7 @@ "---Docs---", "dashboard", + "framework", "agents", "web-checkout", "integrations", diff --git a/src/components/DocsHeader.tsx b/src/components/DocsHeader.tsx index 0ecac2fd..501542b1 100644 --- a/src/components/DocsHeader.tsx +++ b/src/components/DocsHeader.tsx @@ -20,7 +20,9 @@ const SDK_TAB_LABELS: Record = { android: "Android", expo: "Expo", flutter: "Flutter", + kmp: "KMP", unity: "Unity", + web: "Web", "react-native": "React Native", community: "Community", }; diff --git a/src/lib/llms.ts b/src/lib/llms.ts index 8470d8ec..866d21fc 100644 --- a/src/lib/llms.ts +++ b/src/lib/llms.ts @@ -5,6 +5,10 @@ export const llmsSectionConfigs = { label: "Dashboard", urlPrefix: "/docs/dashboard", }, + framework: { + label: "Framework", + urlPrefix: "/docs/framework", + }, agents: { label: "Agents", urlPrefix: "/docs/agents", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a8e45ada..79bd0392 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -36,6 +36,8 @@ import { Route as IosLlmsDottxtRouteImport } from './routes/ios/llms[.]txt' import { Route as IosLlmsFullDottxtRouteImport } from './routes/ios/llms-full[.]txt' import { Route as IntegrationsLlmsDottxtRouteImport } from './routes/integrations/llms[.]txt' import { Route as IntegrationsLlmsFullDottxtRouteImport } from './routes/integrations/llms-full[.]txt' +import { Route as FrameworkLlmsDottxtRouteImport } from './routes/framework/llms[.]txt' +import { Route as FrameworkLlmsFullDottxtRouteImport } from './routes/framework/llms-full[.]txt' import { Route as FlutterLlmsDottxtRouteImport } from './routes/flutter/llms[.]txt' import { Route as FlutterLlmsFullDottxtRouteImport } from './routes/flutter/llms-full[.]txt' import { Route as ExpoLlmsDottxtRouteImport } from './routes/expo/llms[.]txt' @@ -192,6 +194,16 @@ const IntegrationsLlmsFullDottxtRoute = path: '/integrations/llms-full.txt', getParentRoute: () => rootRouteImport, } as any) +const FrameworkLlmsDottxtRoute = FrameworkLlmsDottxtRouteImport.update({ + id: '/framework/llms.txt', + path: '/framework/llms.txt', + getParentRoute: () => rootRouteImport, +} as any) +const FrameworkLlmsFullDottxtRoute = FrameworkLlmsFullDottxtRouteImport.update({ + id: '/framework/llms-full.txt', + path: '/framework/llms-full.txt', + getParentRoute: () => rootRouteImport, +} as any) const FlutterLlmsDottxtRoute = FlutterLlmsDottxtRouteImport.update({ id: '/flutter/llms.txt', path: '/flutter/llms.txt', @@ -293,6 +305,8 @@ export interface FileRoutesByFullPath { '/expo/llms.txt': typeof ExpoLlmsDottxtRoute '/flutter/llms-full.txt': typeof FlutterLlmsFullDottxtRoute '/flutter/llms.txt': typeof FlutterLlmsDottxtRoute + '/framework/llms-full.txt': typeof FrameworkLlmsFullDottxtRoute + '/framework/llms.txt': typeof FrameworkLlmsDottxtRoute '/integrations/llms-full.txt': typeof IntegrationsLlmsFullDottxtRoute '/integrations/llms.txt': typeof IntegrationsLlmsDottxtRoute '/ios/llms-full.txt': typeof IosLlmsFullDottxtRoute @@ -337,6 +351,8 @@ export interface FileRoutesByTo { '/expo/llms.txt': typeof ExpoLlmsDottxtRoute '/flutter/llms-full.txt': typeof FlutterLlmsFullDottxtRoute '/flutter/llms.txt': typeof FlutterLlmsDottxtRoute + '/framework/llms-full.txt': typeof FrameworkLlmsFullDottxtRoute + '/framework/llms.txt': typeof FrameworkLlmsDottxtRoute '/integrations/llms-full.txt': typeof IntegrationsLlmsFullDottxtRoute '/integrations/llms.txt': typeof IntegrationsLlmsDottxtRoute '/ios/llms-full.txt': typeof IosLlmsFullDottxtRoute @@ -381,6 +397,8 @@ export interface FileRoutesById { '/expo/llms.txt': typeof ExpoLlmsDottxtRoute '/flutter/llms-full.txt': typeof FlutterLlmsFullDottxtRoute '/flutter/llms.txt': typeof FlutterLlmsDottxtRoute + '/framework/llms-full.txt': typeof FrameworkLlmsFullDottxtRoute + '/framework/llms.txt': typeof FrameworkLlmsDottxtRoute '/integrations/llms-full.txt': typeof IntegrationsLlmsFullDottxtRoute '/integrations/llms.txt': typeof IntegrationsLlmsDottxtRoute '/ios/llms-full.txt': typeof IosLlmsFullDottxtRoute @@ -427,6 +445,8 @@ export interface FileRouteTypes { | '/expo/llms.txt' | '/flutter/llms-full.txt' | '/flutter/llms.txt' + | '/framework/llms-full.txt' + | '/framework/llms.txt' | '/integrations/llms-full.txt' | '/integrations/llms.txt' | '/ios/llms-full.txt' @@ -471,6 +491,8 @@ export interface FileRouteTypes { | '/expo/llms.txt' | '/flutter/llms-full.txt' | '/flutter/llms.txt' + | '/framework/llms-full.txt' + | '/framework/llms.txt' | '/integrations/llms-full.txt' | '/integrations/llms.txt' | '/ios/llms-full.txt' @@ -514,6 +536,8 @@ export interface FileRouteTypes { | '/expo/llms.txt' | '/flutter/llms-full.txt' | '/flutter/llms.txt' + | '/framework/llms-full.txt' + | '/framework/llms.txt' | '/integrations/llms-full.txt' | '/integrations/llms.txt' | '/ios/llms-full.txt' @@ -559,6 +583,8 @@ export interface RootRouteChildren { ExpoLlmsDottxtRoute: typeof ExpoLlmsDottxtRoute FlutterLlmsFullDottxtRoute: typeof FlutterLlmsFullDottxtRoute FlutterLlmsDottxtRoute: typeof FlutterLlmsDottxtRoute + FrameworkLlmsFullDottxtRoute: typeof FrameworkLlmsFullDottxtRoute + FrameworkLlmsDottxtRoute: typeof FrameworkLlmsDottxtRoute IntegrationsLlmsFullDottxtRoute: typeof IntegrationsLlmsFullDottxtRoute IntegrationsLlmsDottxtRoute: typeof IntegrationsLlmsDottxtRoute IosLlmsFullDottxtRoute: typeof IosLlmsFullDottxtRoute @@ -768,6 +794,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IntegrationsLlmsFullDottxtRouteImport parentRoute: typeof rootRouteImport } + '/framework/llms.txt': { + id: '/framework/llms.txt' + path: '/framework/llms.txt' + fullPath: '/framework/llms.txt' + preLoaderRoute: typeof FrameworkLlmsDottxtRouteImport + parentRoute: typeof rootRouteImport + } + '/framework/llms-full.txt': { + id: '/framework/llms-full.txt' + path: '/framework/llms-full.txt' + fullPath: '/framework/llms-full.txt' + preLoaderRoute: typeof FrameworkLlmsFullDottxtRouteImport + parentRoute: typeof rootRouteImport + } '/flutter/llms.txt': { id: '/flutter/llms.txt' path: '/flutter/llms.txt' @@ -916,6 +956,8 @@ const rootRouteChildren: RootRouteChildren = { ExpoLlmsDottxtRoute: ExpoLlmsDottxtRoute, FlutterLlmsFullDottxtRoute: FlutterLlmsFullDottxtRoute, FlutterLlmsDottxtRoute: FlutterLlmsDottxtRoute, + FrameworkLlmsFullDottxtRoute: FrameworkLlmsFullDottxtRoute, + FrameworkLlmsDottxtRoute: FrameworkLlmsDottxtRoute, IntegrationsLlmsFullDottxtRoute: IntegrationsLlmsFullDottxtRoute, IntegrationsLlmsDottxtRoute: IntegrationsLlmsDottxtRoute, IosLlmsFullDottxtRoute: IosLlmsFullDottxtRoute, diff --git a/src/routes/framework/llms-full[.]txt.ts b/src/routes/framework/llms-full[.]txt.ts new file mode 100644 index 00000000..3b10190d --- /dev/null +++ b/src/routes/framework/llms-full[.]txt.ts @@ -0,0 +1,10 @@ +import { buildLLMFullResponseForSection } from "@/lib/llms"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/framework/llms-full.txt")({ + server: { + handlers: { + GET: () => buildLLMFullResponseForSection("framework"), + }, + }, +}); diff --git a/src/routes/framework/llms[.]txt.ts b/src/routes/framework/llms[.]txt.ts new file mode 100644 index 00000000..047b5555 --- /dev/null +++ b/src/routes/framework/llms[.]txt.ts @@ -0,0 +1,10 @@ +import { buildLLMSummaryResponseForSection } from "@/lib/llms"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/framework/llms.txt")({ + server: { + handlers: { + GET: () => buildLLMSummaryResponseForSection("framework"), + }, + }, +}); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 10f25023..1156b9e6 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -82,6 +82,13 @@ const docsCards: DocCard[] = [ href: buildDocsPath("dashboard"), icon: , }, + { + title: "Framework", + description: + "Build paywalls, onboarding funnels, and web checkout flows as React mini-apps in your repo.", + href: buildDocsPath("framework"), + icon: , + }, { title: "Superwall Agents", description: @@ -146,12 +153,24 @@ const sdkCards: DocCard[] = [ href: buildDocsPath("flutter"), icon: , }, + { + title: "KMP", + description: "Integrate Superwall into your Kotlin Multiplatform app.", + href: buildDocsPath("kmp"), + icon: , + }, { title: "Unity (Beta)", description: "Integrate Superwall into your Unity mobile game.", href: buildDocsPath("unity"), icon: , }, + { + title: "Web (Beta)", + description: "Present paywalls and take payments in your web app.", + href: buildDocsPath("web"), + icon: , + }, { title: "React Native (Legacy)", description: "Legacy SDK for React Native. Migrate to the Expo SDK for new projects.", From 568e22bd801ba685dc7b7b491a2d1e03c5a279ab Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Wed, 19 Aug 2026 18:37:47 +0200 Subject: [PATCH 2/7] fix: tweaks --- content/docs/framework/config.mdx | 2 +- content/docs/framework/purchases.mdx | 4 ++-- content/docs/framework/transitions.mdx | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/framework/config.mdx b/content/docs/framework/config.mdx index ad277ecc..5271eec3 100644 --- a/content/docs/framework/config.mdx +++ b/content/docs/framework/config.mdx @@ -63,7 +63,7 @@ presentation: { } ``` -- **`style`** — `fullscreen` covers the screen, `modal` presents as a card, `push` slides in like a navigation push, `drawer` rises from the bottom edge to the height you set, `popup` floats as a centered window, and `noAnimation` appears instantly. +- **`style`** — `fullscreen` covers the screen, `modal` uses the platform's modal presentation, `push` pushes onto the navigation hierarchy, `drawer` rises from the bottom edge to the height you set, `popup` floats as a centered window, and `noAnimation` presents modally without animating. - **`condition`** — with the default `checkUserSubscription`, the SDK skips presentation for users who are already subscribed. Use `always` to show the paywall regardless. - **`drawer` and `popup`** take sizing options that only apply to their matching style. diff --git a/content/docs/framework/purchases.mdx b/content/docs/framework/purchases.mdx index b44ca78c..08502592 100644 --- a/content/docs/framework/purchases.mdx +++ b/content/docs/framework/purchases.mdx @@ -59,7 +59,7 @@ Both default to what [`config.ts`](/framework/config) declares (`dismissOnPurcha ## The two channels -Your `purchase()` call is one channel. The SDK reporting on its own is the other — and it reports transactions **whoever started them**. A successful restore arrives as a `transaction_complete` event with no purchase call in sight; so does a purchase completed from a re-presented paywall. +Your `purchase()` call is one channel. The SDK reporting on its own is the other — and it reports transactions **whoever started them**. A successful restore arrives as a `transaction_complete` event with no purchase call in sight. ```tsx // this paywall's own attempt @@ -86,7 +86,7 @@ const { restore } = useActions(); ``` -`restore()` is fire-and-forget — there is no result to await. Success surfaces as a `transaction_complete` event or a dismissed paywall; nothing surfaces on failure. Every store paywall should offer restore — App Review expects it. +`restore()` is fire-and-forget — there is no result to await. Success surfaces as a `transaction_complete` event or a dismissed paywall. Every store paywall should offer restore — App Review expects it. ## Haptics on outcomes diff --git a/content/docs/framework/transitions.mdx b/content/docs/framework/transitions.mdx index cf1aed97..f75b5eb9 100644 --- a/content/docs/framework/transitions.mdx +++ b/content/docs/framework/transitions.mdx @@ -3,12 +3,12 @@ title: "Transitions" description: "Built-in page transitions, where to set them, and how to define your own with nothing but a name and CSS." --- -Every navigation animates. The framework ships four built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases — no registration, no JavaScript. +Navigation animates by default. The framework ships four built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases — no registration, no JavaScript. ## Built-ins -- **`push`** — iOS-style: the new page slides in from the right while the one behind recedes. The default. -- **`slide`** — the new page slides over the current one. +- **`push`** — iOS-style: the new page slides in from the right while the one behind shifts back and dims. The default. +- **`slide`** — both pages travel: the new one slides in from the right as the current one slides out to the left. - **`fade`** — a crossfade, one layer at a time. - **`none`** — instant. @@ -32,7 +32,7 @@ Three CSS variables adjust timing and feel without replacing anything: :root { --sw-transition: 500ms; /* duration */ --sw-ease: cubic-bezier(0.28, 0.4, 0.08, 1); - --sw-stack-dim: 0.9; /* how much the page behind dims */ + --sw-stack-dim: 0.925; /* how much the page behind dims (the default) */ } ``` From dfed0b7886bf862b3fe5d3810f9096c6e9d15c34 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 25 Aug 2026 17:18:28 +0200 Subject: [PATCH 3/7] fix: add query state docs --- content/docs/framework/config.mdx | 18 +++- content/docs/framework/examples.mdx | 2 +- content/docs/framework/hooks.mdx | 13 +++ content/docs/framework/meta.json | 1 + content/docs/framework/navigation.mdx | 21 ++++- content/docs/framework/transitions.mdx | 5 +- content/docs/framework/web-checkout.mdx | 2 +- content/docs/framework/web-funnels.mdx | 119 ++++++++++++++++++++++++ 8 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 content/docs/framework/web-funnels.mdx diff --git a/content/docs/framework/config.mdx b/content/docs/framework/config.mdx index 5271eec3..fbda1c8e 100644 --- a/content/docs/framework/config.mdx +++ b/content/docs/framework/config.mdx @@ -33,9 +33,11 @@ export default definePaywall({ ...shared, name: "Pro", products: { … } }); | --- | --- | --- | --- | | `name` | `string` | directory name, title-cased | The label shown in the dashboard. The directory stays the identifier. | | `products` | `Record` | — | Product slots by reference — see [below](#products). | -| `transition` | `"push" \| "slide" \| "fade" \| "none"` or custom | `"push"` | Default page transition — see [Transitions](/framework/transitions). | +| `transition` | `"push" \| "slide" \| "fade" \| "shift" \| "none"` or custom | `"push"` | Default page transition — see [Transitions](/framework/transitions). | +| `queryState` | `boolean` | on when `checkout` is set | Keep the route stack and every `useQueryState` value in the page URL, so the flow resumes from any link. Never applies on a native host — see [Pages & navigation](/framework/navigation). | | `checkout` | mode or `{ mode, prefetch? }` | — | Sell on the web. Omit for native-only — see [Web checkout](/framework/web-checkout). | | `presentation` | see [below](#presentation) | — | How the native SDK presents the paywall. | +| `background` | `string` or `{ light, dark? }` | — | Background color painted behind the paywall while it loads and as the page background — see [below](#background). | | `featureGating` | `"gated" \| "nonGated"` | `"nonGated"` | Whether users must pay to pass the placement. | | `introductoryOfferEligibility` | `"automatic" \| "alwaysEligible" \| "alwaysIneligible"` | `"automatic"` | Trial eligibility — `automatic` lets the store decide. | | `dismissOnPurchase` | `boolean` | — | Auto-dismiss the paywall after a completed purchase. | @@ -57,16 +59,26 @@ The `presentation` object controls how the native SDK presents the paywall over ```ts presentation: { style: "fullscreen" | "modal" | "push" | "drawer" | "popup" | "noAnimation", // default "fullscreen" - condition: "checkUserSubscription" | "always", // default "checkUserSubscription" drawer: { height, cornerRadius }, // when style === "drawer" popup: { width, height, cornerRadius }, // when style === "popup" } ``` - **`style`** — `fullscreen` covers the screen, `modal` uses the platform's modal presentation, `push` pushes onto the navigation hierarchy, `drawer` rises from the bottom edge to the height you set, `popup` floats as a centered window, and `noAnimation` presents modally without animating. -- **`condition`** — with the default `checkUserSubscription`, the SDK skips presentation for users who are already subscribed. Use `always` to show the paywall regardless. - **`drawer` and `popup`** take sizing options that only apply to their matching style. +## Background + +Set the color that sits behind the paywall. The string shorthand sets it for light mode; the object form adds a dark-mode color: + +```ts +background: "#0d0f12" +// or +background: { light: "#ffffff", dark: "#0d0f12" } +``` + +It does two things from one value: native SDKs paint it behind the webview and derive the loading spinner from it, and the web document uses it as the page background — so the color a shopper sees while the paywall loads matches the color it settles on. It takes effect on the next `superwall publish`; when absent, paywalls fall back to the platform default. + ## Products Products are **slots**. The key is the reference your code uses; the value is the store identifier: diff --git a/content/docs/framework/examples.mdx b/content/docs/framework/examples.mdx index 791a9791..22c96999 100644 --- a/content/docs/framework/examples.mdx +++ b/content/docs/framework/examples.mdx @@ -41,7 +41,7 @@ All examples are public at [github.com/superwall/superwall/tree/main/examples](h | [`trial-eligibility`](https://github.com/superwall/superwall/tree/main/examples/trial-eligibility) | Two paywalls in one, chosen by the store — every string forks and both states read as intentional | | [`abandonment-offer`](https://github.com/superwall/superwall/tree/main/examples/abandonment-offer) | `purchase()` resolving `abandoned` is a signal only this paywall can act on — a last-chance offer on a custom `sheet` transition | | [`trial-reminders`](https://github.com/superwall/superwall/tree/main/examples/trial-reminders) | A local notification declared in config, scheduled by the SDK when the trial starts | -| [`web-funnel`](https://github.com/superwall/superwall/tree/main/examples/web-funnel) | Selling on the web: steps as pages, then `checkout: "sheet"` — one config key, `purchase()` unchanged | +| [`web-funnel`](https://github.com/superwall/superwall/tree/main/examples/web-funnel) | Selling on the web: steps as pages on the `shift` transition, answers kept in the URL with `useQueryState`, then `checkout: "sheet"` — `purchase()` unchanged | `purchase-states` is the only example showing the full haptic vocabulary (`success()` / `error()` keyed to outcomes). `abandonment-offer` holds the deepest CSS lesson of the set: the scrim behind its sheet reuses the framework's timing variables, so one number drives both the page dim and the backdrop — see [Transitions](/framework/transitions). diff --git a/content/docs/framework/hooks.mdx b/content/docs/framework/hooks.mdx index 71090453..4c13cf23 100644 --- a/content/docs/framework/hooks.mdx +++ b/content/docs/framework/hooks.mdx @@ -159,6 +159,19 @@ const focused = useIsFocused(); Whether this page is on top of the stack. Pages you navigate away from stay alive — a covered page can't be clicked or focused, and `useIsFocused()` tells it so, so it can pause video or timers. See [Pages & navigation](/framework/navigation). +## `useQueryState(key, parser?)` + +```tsx +import { parseAsStringEnum, useQueryState } from "superwall/navigation"; + +const [goal, setGoal] = useQueryState("goal", parseAsStringEnum(["focus", "habit"])); +const [name, setName] = useQueryState("name"); // string | null +setGoal("focus"); // ?goal=focus +setGoal(null); // key removed +``` + +`useState` whose value lives in the page URL, so a flow resumes from any link — after a reload, in the OS browser after an in-app one, or back from hosted checkout. On a surface with web checkout the URL is kept automatically, and **every answer in a web funnel goes through this hook** — single choice, multi choice, inputs, the selected plan — never `useState`; see [Web Funnels](/framework/web-funnels). On a native host the same hook is plain state shared across pages. The API is nuqs's: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, plus `.withDefault()` and `.withOptions({ history, clearOnDefault })`. See [Pages & navigation](/framework/navigation). + ## `ProductReference` ```tsx diff --git a/content/docs/framework/meta.json b/content/docs/framework/meta.json index df0c84a8..be616cba 100644 --- a/content/docs/framework/meta.json +++ b/content/docs/framework/meta.json @@ -21,6 +21,7 @@ "purchases", "trials", "web-checkout", + "web-funnels", "---Connecting to Your App---", "variables", diff --git a/content/docs/framework/navigation.mdx b/content/docs/framework/navigation.mdx index 65e5d480..ffec7a6f 100644 --- a/content/docs/framework/navigation.mdx +++ b/content/docs/framework/navigation.mdx @@ -84,6 +84,25 @@ const choose = (value: Goal) => { Guard every read on the destination — `answers.goal ? PLAN[answers.goal] : undefined` — so a revisited page never crashes on a missing answer. +**The URL**, for flows on the web — and on a web funnel this is not one option among three, it is the rule. Neither home above survives a reload, and an in-app browser (Instagram, TikTok) hands only the link to Safari when someone taps "open in browser" — its storage stays behind. So a surface with [web checkout](/framework/web-checkout) keeps its state in the page URL: the route stack goes in automatically, and **every answer, selection and input** is kept with `useQueryState`, never `useState`: + +```tsx +import { parseAsStringEnum, useQueryState } from "superwall/navigation"; + +const [goal, setGoal] = useQueryState("goal", parseAsStringEnum(["focus", "habit"])); + +setGoal("focus"); // ?goal=focus — and the plan page reads the same hook +router.push("plan"); +``` + +The API is [nuqs](https://nuqs.dev)'s, so the parsers read the same: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, each with `.withDefault()` and `.withOptions({ history, clearOnDefault })`. Any link then resumes the flow on the same step with the same answers — after a reload, in the OS browser, or back from hosted checkout — and the browser's back button is `router.back()`. + +[Web Funnels](/framework/web-funnels) has the full treatment — multi choice, inputs, branching, the URL budget. Three rules keep it honest: + +- **Only what a page asks for is persisted.** The framework never decides what an answer is. Keys starting with `sw_` are reserved. +- **Mind the URL budget.** About 2 kB is safe across every app and share sheet — keep keys short and values enumerable, and keep anything personal out of a URL. +- **The same code runs natively.** In an SDK webview there is no URL bar, so `useQueryState` is plain state shared across pages — the flow reads identically everywhere. `definePaywall({ queryState: true | false })` overrides the checkout default in either direction. + ## Shared chrome Put back buttons, step counters, and the close button in `layout.tsx`, and drive them from router state so they can never drift from the stack: @@ -116,7 +135,7 @@ Position overlay chrome absolutely *over* the pages rather than as a bar above t Multi-step flows — onboarding quizzes, web funnels — are **one paywall whose steps are pages**, not a chain of separate paywalls. Every step is a `router.push` in the same flow, so there's no load between steps and nothing to re-fetch. The structure is identical — `config.ts` plus `app/` pages plus `layout.tsx` — and funnels live in `superwall/funnels//` with exactly the same shape. -The web funnel example is the reference: question steps, a typed plan selector, then `purchase(reference)` at the end — with [web checkout](/framework/web-checkout) taking payment in the same flow. +The web funnel example is the reference: question steps kept in the URL, a typed plan selector, then `purchase(reference)` at the end — with [web checkout](/framework/web-checkout) taking payment in the same flow. Funnels usually want `transition: "shift"` in `config.ts` — see [Transitions](/framework/transitions). ## Where transitions and animation fit diff --git a/content/docs/framework/transitions.mdx b/content/docs/framework/transitions.mdx index f75b5eb9..33a81683 100644 --- a/content/docs/framework/transitions.mdx +++ b/content/docs/framework/transitions.mdx @@ -3,13 +3,14 @@ title: "Transitions" description: "Built-in page transitions, where to set them, and how to define your own with nothing but a name and CSS." --- -Navigation animates by default. The framework ships four built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases — no registration, no JavaScript. +Navigation animates by default. The framework ships five built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases — no registration, no JavaScript. ## Built-ins - **`push`** — iOS-style: the new page slides in from the right while the one behind shifts back and dims. The default. - **`slide`** — both pages travel: the new one slides in from the right as the current one slides out to the left. - **`fade`** — a crossfade, one layer at a time. +- **`shift`** — the funnel step: the new page fades in as it drifts the last 44px into place, and the page it replaces is simply gone. One layer moves at a time, so a long flow never reads as a stack. Set it once in `config.ts` for onboarding quizzes and web funnels. - **`none`** — instant. ## Set them at three levels @@ -33,6 +34,8 @@ Three CSS variables adjust timing and feel without replacing anything: --sw-transition: 500ms; /* duration */ --sw-ease: cubic-bezier(0.28, 0.4, 0.08, 1); --sw-stack-dim: 0.925; /* how much the page behind dims (the default) */ + --sw-transition-fade: 350ms; --sw-ease-fade: ease; /* fade runs on its own clock */ + --sw-transition-shift: 420ms; --sw-ease-shift: cubic-bezier(0.2, 0, 0, 1); } ``` diff --git a/content/docs/framework/web-checkout.mdx b/content/docs/framework/web-checkout.mdx index d722416f..0acc8ae2 100644 --- a/content/docs/framework/web-checkout.mdx +++ b/content/docs/framework/web-checkout.mdx @@ -85,4 +85,4 @@ It takes no colors, fonts, or spacing from the page around it, and there's no pr ## A full web funnel -The `web-funnel` [example](/framework/examples) is the reference: question steps as pages, a typed plan selector with on-selection prefetch, then `purchase(reference)` — the whole flow in one paywall. +The `web-funnel` [example](/framework/examples) is the reference: question steps as pages, a typed plan selector with on-selection prefetch, then `purchase(reference)` — the whole flow in one paywall. Because `checkout` is set, the flow's step and answers live in the page URL, so it resumes from any link — in Safari after an in-app browser, or back from hosted checkout. Keep every answer in `useQueryState` — [Web Funnels](/framework/web-funnels) is the guide. diff --git a/content/docs/framework/web-funnels.mdx b/content/docs/framework/web-funnels.mdx new file mode 100644 index 00000000..6e53d316 --- /dev/null +++ b/content/docs/framework/web-funnels.mdx @@ -0,0 +1,119 @@ +--- +title: "Web Funnels" +description: "Quizzes and checkout funnels on the web: one paywall whose steps are pages, every answer kept in the URL so the flow survives any browser hand-off, and payment at the end." +--- + +A web funnel is a paywall with `checkout` set: a few question pages, a plan, then `purchase()`. It is served as a normal web page — and a web page has one problem a native paywall never has: **the person may change browsers halfway through.** A link opened from Instagram or TikTok runs in that app's in-app browser; tapping "Open in Safari" (or being sent there to pay with Apple Pay) hands over the URL and nothing else. `localStorage`, cookies, React state — all of it stays behind. Hosted checkout comes back to a URL too, and a reload starts from scratch. + +So a web funnel keeps its state in the URL. The router does its half automatically; your half is one rule. + +## The rule: every answer is `useQueryState` + +On a web funnel, **never hold an answer in `useState`, layout context, or a module.** Single choice, multi choice, text input, the selected plan — anything the person entered lives in `useQueryState`, so any URL resumes the flow on the same step with the same answers. + +```tsx +import { parseAsArrayOf, parseAsStringEnum, useQueryState } from "superwall/navigation"; + +const GOALS = ["focus", "habit", "catch-up"] as const; +const goalParser = parseAsStringEnum(GOALS); + +// single choice — set, then move on +const [goal, setGoal] = useQueryState("goal", goalParser); +const choose = (value: (typeof GOALS)[number]) => { + haptics.selection(); + setGoal(value); + router.push("interests"); +}; +``` + +```tsx +// multi choice — an array of enum ids, toggled in place +const [interests, setInterests] = useQueryState( + "interests", + parseAsArrayOf(parseAsStringEnum(["reading", "writing", "speaking"])).withDefault([]), +); +const toggle = (id: Interest) => + setInterests((current) => + current.includes(id) ? current.filter((one) => one !== id) : [...current, id], + ); +``` + +```tsx +// text input — a plain string; replaces are coalesced, so typing is safe +const [name, setName] = useQueryState("name"); + setName(event.target.value || null)} /> +``` + +```tsx +// the selected plan, typed against config +const [plan, setPlan] = useQueryState("plan", parseAsStringEnum(["monthly", "annual"]).withDefault("annual")); +``` + +Every later page reads the same hook — the plan page shows `goal`, the summary page lists `interests`, and `purchase(plan)` uses the selection — with no context and no prop drilling. Guard reads on pages someone might land on directly: `goal ? COPY[goal] : COPY.default`. + +The API is [nuqs](https://nuqs.dev)'s, so its parsers read the same: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, each with `.withDefault()` (removes `null` from the type and clears the key when the value equals the default) and `.withOptions({ history, clearOnDefault })`. Junk in the URL parses to the default. Full signatures are in [Hooks](/framework/hooks#usequerystatekey-parser). + + +The same hook on a native host — where there is no URL bar — is plain state shared across pages. A funnel written this way runs unchanged natively; only where the state is kept differs. + + +## What the router does on its own + +With `checkout` set, `queryState` defaults to on and the route stack is mirrored into one reserved param: + +``` +https://yourapp.superwall.app/funnel?sw_nav=index,goal,interests&goal=habit&interests=reading,speaking +``` + +- `router.push` adds a browser history entry; back, replace and dismiss rewrite in place. **The browser's back button is `router.back()`** — including Android's hardware back. +- Any URL rebuilds the stack it names, with no animation and one `entry` page view. A route that no longer exists starts the flow over at `index`. +- Writes are coalesced so a text input can't trip Safari's history rate limit, and anything pending is flushed the moment the page is hidden — the instant before a hand-off or the jump to hosted checkout. +- Foreign params (`utm_*`, attribution) are left untouched, and survive the whole flow. + +`definePaywall({ queryState: false })` turns it off for a checkout surface; `queryState: true` turns it on for a web surface without checkout. See [Config](/framework/config). + +## Branch on the answers + +Branching reads the same state, so a branch taken before a hand-off is the branch resumed after it: + +```tsx +const [goal] = useQueryState("goal", goalParser); +const next = () => router.push(goal === "catch-up" ? "backlog" : "interests"); +``` + +Because the stack is in the URL as the routes actually visited, back always retraces the branch taken. + +## Keep the URL small and clean + +About 2 kB is safe across every app and share sheet, and a question flow of twenty short answers is well under 500 bytes if you follow three habits: + +- **Enumerate.** Store ids (`"habit"`), never labels (`"Build a habit"`). `parseAsStringEnum` gives you validation for free. +- **Short keys, cleared defaults.** `goal`, not `selectedGoalOption`; leave `clearOnDefault` on so untouched answers cost nothing. +- **Nothing personal.** URLs end up in referrer and analytics logs. An email or a name belongs in the checkout sheet's own fields, not in the query string. + +Keys starting with `sw_`, plus `platform` and `transport`, are reserved — the hook throws on them. + +## Move like a funnel + +Set the funnel transition once; `shift` fades each step in as it drifts into place and drops the previous step outright, so a long flow never reads as a growing stack: + +```ts +export default definePaywall({ + name: "Onboarding", + transition: "shift", + checkout: { mode: "sheet", prefetch: "annual" }, + products: { monthly: "live:price_…:no-trial", annual: "live:price_…:7days-free" }, +}); +``` + +Then the plan page prefetches the selected product and `purchase(plan)` opens the sheet — see [Web Checkout](/framework/web-checkout). + +## Checklist + +- `checkout` set in `config.ts`; `transition: "shift"` +- every answer, selection and input is `useQueryState` — no `useState` for anything the person entered +- enum ids, short keys, defaults cleared, nothing personal +- later pages guard their reads, so a direct link never crashes +- test it: answer two questions, copy the studio's iframe URL into a new tab, and you should land on the same step with the same answers + +The `web-funnel` [example](/framework/examples) is the reference. From a53c0a2e62ac17826b126b9cd5fad051a70253a3 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 25 Aug 2026 17:39:09 +0200 Subject: [PATCH 4/7] fix: docs tweaks --- content/docs/framework/assets.mdx | 8 +- content/docs/framework/examples.mdx | 11 --- content/docs/framework/hooks.mdx | 2 +- content/docs/framework/lifecycle.mdx | 2 +- content/docs/framework/navigation.mdx | 1 - content/docs/framework/purchases.mdx | 18 +---- content/docs/framework/quickstart.mdx | 5 +- content/docs/framework/styling.mdx | 86 ++++++++++------------ content/docs/framework/troubleshooting.mdx | 4 +- 9 files changed, 49 insertions(+), 88 deletions(-) diff --git a/content/docs/framework/assets.mdx b/content/docs/framework/assets.mdx index 2dbe77f1..dbbec7aa 100644 --- a/content/docs/framework/assets.mdx +++ b/content/docs/framework/assets.mdx @@ -73,12 +73,7 @@ A relative-path `@font-face` is the whole setup: :root { --sans: "Manrope Custom", ui-sans-serif, system-ui, sans-serif; } ``` -A few habits keep fonts cheap: - -- **Subset before you ship.** A full variable font carries alphabets the paywall will never render — latin-only Manrope is around 24 kB against roughly 90 kB for the whole family. -- **Ship woff2.** Anything older is bytes for nothing you support. -- **Google Fonts go in a CSS `@import`**, at the top of the stylesheet — never React-rendered `` tags. The stylesheet ships in the page itself, so the browser finds the `@import` immediately; a rendered `` waits for JavaScript to run first, and the text flashes. -- One family plus one mono is a good budget. +Google Fonts go in a CSS `@import` at the top of the stylesheet — never a React-rendered `` tag. The stylesheet ships in the page itself, so the browser finds the `@import` immediately; a rendered `` waits for JavaScript to run first, and the text flashes. The custom-fonts [example](/framework/examples) shows a local file and a Google Fonts import side by side. @@ -124,5 +119,4 @@ Nothing to do — while the user is on the current page, the next pages' images, ## Keep it light - Big imagery is fine — it's served from the CDN and cached, not carried by the paywall itself. -- Compress and size media for a phone screen; every open pays for what the paywall loads. - Pushing files over 50 MB warns — every future clone of the source pays for them — but nothing is capped. diff --git a/content/docs/framework/examples.mdx b/content/docs/framework/examples.mdx index 22c96999..4cedb1f9 100644 --- a/content/docs/framework/examples.mdx +++ b/content/docs/framework/examples.mdx @@ -67,14 +67,3 @@ All examples are public at [github.com/superwall/superwall/tree/main/examples](h | Example | The one idea | | --- | --- | | [`localization`](https://github.com/superwall/superwall/tree/main/examples/localization) | Four locales by filename, shared + paywall-local catalogs, guarded `{price}` interpolation, no language picker on device | - -## Habits every example carries - -These are the "treat it like a real paywall" conventions — carry them into anything you build: - -- **Haptics on every meaningful tap** — `light()` for navigation and CTAs, `selection()` for changing a choice, `success()` when a purchase lands ([Styling & mobile design](/framework/styling)). -- **Never a loading state on the buy button** — the store sheet is the feedback, and the SDK owns it ([Purchases](/framework/purchases)). -- **Prices from `useProducts()`**, never hardcoded; example product identifiers are placeholders to repoint at your own ([Products](/framework/products)). -- **Every control reachable** — icon-only buttons carry `aria-label`, tap targets are at least 44px, primary actions sit full-width at the bottom. -- **Links through `openUrl`**, never an `
` ([Actions](/framework/actions)). -- **Light and dark via the `:root.dark` class**, both always checked; safe areas with sensible minimums; responsive from 320px to tablet. diff --git a/content/docs/framework/hooks.mdx b/content/docs/framework/hooks.mdx index 4c13cf23..7a06490c 100644 --- a/content/docs/framework/hooks.mdx +++ b/content/docs/framework/hooks.mdx @@ -102,7 +102,7 @@ The same device record as `useVariables().device`, plus `orientation` (`"portrai const scheme = useColorScheme(); // "light" | "dark" ``` -Rarely needed: the framework already keeps a `dark`/`light` class on `` from what the device reports, so style with plain CSS (`:root.dark { … }`). Reach for the hook only when you need the scheme in JavaScript. Never use `@media (prefers-color-scheme: dark)` as the mechanism — see [Styling & mobile design](/framework/styling). +Rarely needed: the framework already keeps a `dark`/`light` class on `` from what the device reports, so style with plain CSS (`:root.dark { … }`). Reach for the hook only when you need the scheme in JavaScript. Never use `@media (prefers-color-scheme: dark)` as the mechanism — see [Styling](/framework/styling). ## `useSuperwallEvent(name, handler)` diff --git a/content/docs/framework/lifecycle.mdx b/content/docs/framework/lifecycle.mdx index 2efea651..1ca1439a 100644 --- a/content/docs/framework/lifecycle.mdx +++ b/content/docs/framework/lifecycle.mdx @@ -73,7 +73,7 @@ The device decides; the framework maintains a `dark`/`light` class on ``. :root.dark { --bg: #1c1b19; --fg: #fdfef6; } ``` -Don't use `@media (prefers-color-scheme: dark)` as the mechanism — it cannot see what the device reports and ignores the studio's theme toggle. The class is the mechanism. [Styling & mobile design](/framework/styling) has the full treatment, including Tailwind. +Don't use `@media (prefers-color-scheme: dark)` as the mechanism — it cannot see what the device reports and ignores the studio's theme toggle. The class is the mechanism. [Styling](/framework/styling) has the full treatment, including Tailwind. ## Dev vs device diff --git a/content/docs/framework/navigation.mdx b/content/docs/framework/navigation.mdx index ffec7a6f..7f8ea182 100644 --- a/content/docs/framework/navigation.mdx +++ b/content/docs/framework/navigation.mdx @@ -50,7 +50,6 @@ If you've used expo-router, this is its API, method for method. Page names autoc A few rules make navigation feel right: - **Closing the paywall is `useActions().close()`**, not navigation. The stack is for moving within the flow; closing hands control back to your app. See [Actions](/framework/actions). -- **Fire `haptics.light()` before every push and back.** iOS gives no feedback of its own on navigation inside a paywall. - **Pages you navigate away from stay alive.** Going back restores a page exactly as it was left, scroll position and state included. A covered page can't be clicked or focused; `useIsFocused()` tells a page it's covered so it can pause video or timers. - **There is no declared page order.** Any page can push any page — which is exactly what makes branching flows possible. - **Page views are tracked for you.** Every navigation reports analytics automatically; there's nothing to instrument. diff --git a/content/docs/framework/purchases.mdx b/content/docs/framework/purchases.mdx index 08502592..0db6278f 100644 --- a/content/docs/framework/purchases.mdx +++ b/content/docs/framework/purchases.mdx @@ -32,13 +32,9 @@ const haptics = useHaptics(); | `abandoned` | The user closed the store sheet | Treat as an ordinary outcome — most people who open a sheet close it. This is the only place *this paywall's own* declined offer is visible: show a last-chance offer, or nothing | | `failed` | No transaction happened — `reason` is `"timeout"` or `"superseded"` (a retry or re-presentation replaced this attempt) | Usually nothing; `haptics.error()` at most | -### Never put the buy button in a loading state +### Reacting to abandoned -No "One moment…", no disabling, no spinner. The store sheet *is* the feedback, and the SDK owns when it appears. A button that visibly waits makes the paywall feel broken in the gap the platform already covers. - -### Abandoned is a signal, not a failure - -Someone opened the sheet and closed it — that's the closest thing a paywall gets to hearing "not at this price." A common pattern is pushing a last-chance offer: +`abandoned` is the one outcome only this paywall can see — the SDK reports transactions, not declines. That makes it the hook for a last-chance offer: ```tsx const result = await purchase(selected); @@ -47,7 +43,7 @@ if (result.status === "abandoned") { } ``` -**One recovery offer, not two.** If the user abandons the discounted offer as well, let them be. The `abandonment-offer` [example](/framework/examples) shows the full pattern — a second product, not a second design. +The `abandonment-offer` [example](/framework/examples) shows the full pattern. ## Options @@ -88,14 +84,6 @@ const { restore } = useActions(); `restore()` is fire-and-forget — there is no result to await. Success surfaces as a `transaction_complete` event or a dismissed paywall. Every store paywall should offer restore — App Review expects it. -## Haptics on outcomes - -iOS fires no feedback of its own inside a paywall, so the vocabulary is yours to supply: - -- `haptics.light()` when the buy button is tapped -- `haptics.success()` when a transaction completes — via the event, so restores count too -- `haptics.error()` sparingly, on `failed` - ## Selling beyond the App Store Trials — who's eligible, what to show each side — have their own page: [Free trials](/framework/trials). And a single config key sells the same paywall on the web through Stripe, with `purchase()` unchanged: [Web checkout](/framework/web-checkout). diff --git a/content/docs/framework/quickstart.mdx b/content/docs/framework/quickstart.mdx index 0152ffdd..ac887c31 100644 --- a/content/docs/framework/quickstart.mdx +++ b/content/docs/framework/quickstart.mdx @@ -87,10 +87,7 @@ export default function Paywall() { } ``` -Two habits worth forming on day one: - -- **Guard every product read.** Prices arrive from the store at runtime; in dev they're `undefined` until the studio injects your dashboard's products. Degrade the copy — never invent a number. See [Products](/framework/products). -- **Fire a haptic on every meaningful tap.** iOS gives no feedback of its own inside a paywall. See [Styling & mobile design](/framework/styling). +One thing to know from day one: **product data arrives at runtime.** Prices come from the store, and in dev they're `undefined` until the studio injects your dashboard's products — so guard every read rather than assuming a number is there. See [Products](/framework/products). diff --git a/content/docs/framework/styling.mdx b/content/docs/framework/styling.mdx index 29aa7eb0..13176a02 100644 --- a/content/docs/framework/styling.mdx +++ b/content/docs/framework/styling.mdx @@ -1,19 +1,25 @@ --- -title: "Styling & Mobile Design" -description: "Dark mode, safe areas, scroll behavior, motion, and touch — the platform conventions that make a paywall feel native inside a webview." +title: "Styling" +description: "How styling works in a paywall — plain CSS, the color-scheme class, the background color, safe areas, and the platform stylesheet Superwall applies at serve time." --- -Paywalls render inside a native webview on a phone. Two things decide whether one feels native: reproducing your design exactly, and following the platform conventions — Apple's HIG and their Android equivalents — that users feel but never name. This page collects the conventions; treat them as working practices, with your design reference always winning over any rule here. +A paywall is styled with ordinary CSS. The framework ships no component library, no theme, and no opinions about how your paywall should look — your stylesheet is the whole story. -## The design is the contract +What the framework does provide is a small set of mechanisms your CSS can rely on: a color-scheme class that follows the device, a background color that reaches the native SDK, and a platform stylesheet applied at serve time. -- **Build 1:1.** Spacing, sizing, weights, colors, and effects come from the design, not from habit. Measure the design at logical points — a screenshot at device width — instead of eyeballing, and compare your build against it side by side before calling it done. -- **Add nothing the design doesn't show.** No extra links, badges, footnotes, or affordances, however well-intentioned. If something seems missing — a restore button, a legal link — raise it with your designer rather than quietly adding it. -- **Effects are design decisions, not defaults.** Shadows, gradients, borders, blurs, and radii belong to the design system of the paywall you're building. If the design is flat, build flat; if it's soft and elevated, match that. +## Plain CSS -## Dark mode +Import a stylesheet from a route and write whatever you like: -The device decides, and the framework maintains a `dark`/`light` class on ``. Style with plain CSS and write no wiring: +```tsx +import "./theme.css"; +``` + +Tailwind works, CSS modules work, and a single hand-written `theme.css` works. Nothing is injected into your styles, and nothing of yours is overridden. + +## Color scheme + +The device decides light or dark, and the framework maintains a `dark`/`light` class on ``. Style off that class and write no wiring: ```css :root { --bg: #fdfef6; --fg: #0c0b0a; } @@ -21,64 +27,52 @@ The device decides, and the framework maintains a `dark`/`light` class on ` -Don't use `@media (prefers-color-scheme: dark)` as the mechanism. The media query can't see what the device reports through the SDK and doesn't respond to the studio's theme toggle — a paywall styled that way looks right on your machine and wrong on the device. The `:root.dark` class is the mechanism. +Don't use `@media (prefers-color-scheme: dark)` as the mechanism. The media query reads the *browser's* setting; the class reflects what the device reports through the SDK, which is the real interface style, and it's what the studio's theme toggle drives. A paywall styled on the media query looks right on your machine and wrong on the device. -Using Tailwind? Redefine the `dark:` variant onto the class so it follows the SDK instead of the media query: +Using Tailwind? Point the `dark:` variant at the class so it follows the SDK: ```css @custom-variant dark (&:where(.dark, .dark *)); ``` -The Tailwind example shows the full setup — see [Examples](/framework/examples). Design both palettes even when the reference shows only one, and check both in the studio. +Read the current scheme in JavaScript with [`useColorScheme()`](/framework/hooks#usecolorscheme). + +## Background color + +`background` in [config](/framework/config#background) sets the paywall's background, in light and dark: + +```ts +background: { light: "#ffffff", dark: "#0d0f12" } +``` + +One value covers both sides of the load: it paints the page background, and it's sent to the native SDK, which paints the same color behind the webview and derives its loading spinner from it. Set it to whatever your page background is, and the paywall has no flash of a different color while it loads. ## Safe areas -`env(safe-area-inset-*)` resolves to **0** in previews and some webview contexts, so bare `env()` math puts controls in the status bar or under the home indicator the moment insets go missing. Always wrap in `max()` with a floor: +The standard `env(safe-area-inset-*)` variables work on device. They resolve to **0** in previews and in some webview contexts, so bare `env()` math collapses to nothing exactly where it matters — a close button lands in the status bar, a CTA under the home indicator. + +Wrap them in `max()` with a floor so the layout survives either case: ```css -/* fixed top chrome (close button): clears the status bar even with no env */ top: max(calc(env(safe-area-inset-top, 0px) + 10px), 60px); - -/* pinned bottom chrome: clears the home indicator */ padding-bottom: max(calc(env(safe-area-inset-bottom, 0px) + 14px), 28px); ``` -Around 60px is a sensible top floor and 28px a bottom floor — adjust the numbers to your design, keep the pattern. Fixed elements (close button, CTA bar) need the inset math; scrolling content instead needs enough bottom padding to clear whatever is pinned over it. - -## Scrollable content - -- Long content scrolls **under** pinned bottom chrome. Give the pinned footer a gradient — transparent to page background — so content fades out behind it instead of clipping to a hard edge. -- Put `pointer-events: none` on the pinned container and `pointer-events: auto` back on its interactive children, so the fade region doesn't swallow scroll gestures. -- Give the scroll content bottom padding of roughly the footer height plus the safe area, so the last row can scroll clear of the fade. -- Let the page itself scroll; don't invent nested scroll areas. The platform — and `scrollEnabled` in [config](/framework/config) — owns scroll behavior. - -## Motion +## Scrolling -- **Animate functional movement only** — elements that physically travel between states: a segmented-control thumb sliding, a sheet presenting, a progress bar filling. Content that merely changes — text, list rows, a price — updates in place; it doesn't fade, slide, or stagger unless the design explicitly calls for it. -- **Press feedback is the baseline interaction**: a scale-down active state (around 0.96, fast in at ~80ms, settling out at ~200ms) on tappable elements, paired with a haptic. For most controls, that's the whole story. -- **Entry animations are opt-in per design** — and when a design has one, it gates on presentation, never mount, because paywalls are preloaded hidden. See [Lifecycle & events](/framework/lifecycle). -- Honor `prefers-reduced-motion` by collapsing durations to ~1ms. +Scroll behavior belongs to the platform. `scrollEnabled` in [config](/framework/config) turns page scrolling on or off, and the platform stylesheet implements it — so let the page itself scroll rather than building nested scroll containers. -## Touch +## Fonts -- **Tap targets are at least 44×44pt.** A visually shorter control — a slim segmented control — can trade height when the design demands it, but width and spacing must compensate. -- **Haptics on every meaningful tap**, via [`useHaptics()`](/framework/hooks#usehaptics): `light` for navigation and CTAs, `selection` for choosing between options, `success` when a purchase lands, `error` sparingly on failures. iOS fires nothing on its own inside a webview. -- **Suppress focus rings on tap-driven controls.** The `:focus-visible` heuristics misfire in webviews and previews, drawing outlines the design never asked for. Keep keyboard focus styles only where a keyboard is real, like web checkout pages. -- On controls: `-webkit-tap-highlight-color: transparent`, `touch-action: manipulation`, `user-select: none`. -- Icon-only buttons carry an `aria-label`; every control stays reachable. +The system font stack is what makes a webview read as native. When a design calls for brand type, a relative-path `@font-face` is the entire setup — see [custom fonts in Assets](/framework/assets#custom-fonts). -## Type and rendering +## The platform stylesheet -- Default to the system font stack — `-apple-system, BlinkMacSystemFont, …` — unless the design specifies brand type. It's what makes a webview read as native iOS. (When the design calls for brand type, see [custom fonts in Assets](/framework/assets).) -- Set `-webkit-text-size-adjust: 100%` on `html`, use antialiased smoothing, and keep body copy around 17px to match iOS body text. +Published paywalls receive a small Superwall-owned stylesheet at serve time, carrying platform-wide behavior like scroll control. It lands *before* your styles in the cascade, so your CSS always wins. Previews apply the same stylesheet, which is what makes local and published render identically. -## Verify like a device +Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer. -In the [studio](/framework/studio), before calling any paywall done: +## Check it in the studio -- Both color schemes. -- The smallest supported width — 320px — through tablet. -- Every page in the flow. -- The trial-eligibility toggle, where relevant. -- Nothing overflows horizontally at any size. +The [studio](/framework/studio) renders a paywall at device sizes with the controls a device would supply — a theme toggle for both color schemes, widths from 320px through tablet, and a trial-eligibility toggle. It's the fastest way to see a stylesheet behave under conditions your browser won't reproduce on its own. diff --git a/content/docs/framework/troubleshooting.mdx b/content/docs/framework/troubleshooting.mdx index 8c36cf53..2976840d 100644 --- a/content/docs/framework/troubleshooting.mdx +++ b/content/docs/framework/troubleshooting.mdx @@ -67,7 +67,7 @@ The SDK preloads paywalls hidden, so components mount long before anyone is look ### Dark mode looks right on my machine, wrong on device -The mechanism is the `dark` class the framework maintains on `` — not `prefers-color-scheme`. A media query can't see what the device reports and ignores the studio's theme toggle. Style off the class, as shown in [Styling & mobile design](/framework/styling). +The mechanism is the `dark` class the framework maintains on `` — not `prefers-color-scheme`. A media query can't see what the device reports and ignores the studio's theme toggle. Style off the class, as shown in [Styling](/framework/styling). ### My link does nothing @@ -75,7 +75,7 @@ Inside a webview, an `` either does nothing or navigates the paywall awa ### Controls sit in the status bar / under the home indicator -`env(safe-area-inset-*)` resolves to 0 in previews and some webview contexts, so bare `env()` math collapses. Always wrap in `max()` with a floor. See [Styling & mobile design](/framework/styling). +`env(safe-area-inset-*)` resolves to 0 in previews and some webview contexts, so bare `env()` math collapses. Always wrap in `max()` with a floor. See [Styling](/framework/styling). ### The payment sheet doesn't open in dev From 3cdaabfc8a06ee0636d6cf8c29b55db3c8962c54 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 25 Aug 2026 18:06:52 +0200 Subject: [PATCH 5/7] fix: audit pass --- content/docs/framework/assets.mdx | 6 ++--- content/docs/framework/cli.mdx | 12 ++++----- content/docs/framework/config.mdx | 27 +++++--------------- content/docs/framework/examples.mdx | 2 +- content/docs/framework/lifecycle.mdx | 2 +- content/docs/framework/localization.mdx | 2 +- content/docs/framework/navigation.mdx | 8 +++--- content/docs/framework/project-structure.mdx | 4 +-- content/docs/framework/push-and-promote.mdx | 8 +++--- content/docs/framework/quickstart.mdx | 2 +- content/docs/framework/studio.mdx | 19 +++++++------- content/docs/framework/styling.mdx | 6 ++--- content/docs/framework/transitions.mdx | 10 ++++---- content/docs/framework/troubleshooting.mdx | 8 +++--- content/docs/framework/variables.mdx | 4 +-- 15 files changed, 55 insertions(+), 65 deletions(-) diff --git a/content/docs/framework/assets.mdx b/content/docs/framework/assets.mdx index dbbec7aa..bc41d378 100644 --- a/content/docs/framework/assets.mdx +++ b/content/docs/framework/assets.mdx @@ -96,7 +96,7 @@ import intro from "@/assets/intro.lottie"; `.riv` files load like any asset, plus one required setup step: ```tsx -import { useRive, RuntimeLoader } from "@rive-app/canvas"; +import { useRive, RuntimeLoader } from "@rive-app/react-canvas"; import riveWasm from "@rive-app/canvas/rive.wasm?url"; import smiley from "../assets/smiley.riv"; @@ -107,14 +107,14 @@ const { RiveComponent } = useRive({ src: smiley, stateMachines: "State Machine 1 ``` -Rive fetches its WebAssembly engine from a CDN by default, and published paywalls cannot reach external CDNs. Bundle the wasm with the `?url` import as above, and null the fallback so a failure stays loud rather than silently retrying a CDN that will never answer. +Rive `fetch`es its WebAssembly engine from a CDN by default, and a published paywall's CSP allows `connect-src` only to Superwall's own origins — so that fetch never lands. Bundle the wasm with the `?url` import as above, and null the fallback so a failure stays loud rather than silently retrying a CDN that will never answer. (Stylesheets, fonts, images and media from `https:` are fine — which is why the Google Fonts `@import` above works.) Also pass the file's **real state-machine name** — naming one that doesn't exist leaves a blank canvas and no error. The with-rive [example](/framework/examples) is the reference. ## Multi-page flows -Nothing to do — while the user is on the current page, the next pages' images, video, and fonts warm automatically. `.lottie` and `.riv` files go further: the SDK pre-caches them on device before the paywall even opens. +Nothing to do — while the user is on the current page, the next pages' images, video, and fonts warm automatically. Every hosted asset a version references is also stamped into the paywall's pre-cache manifest on promote, so the SDK caches it on device before the paywall opens. ## Keep it light diff --git a/content/docs/framework/cli.mdx b/content/docs/framework/cli.mdx index 15ba9675..8bff7b79 100644 --- a/content/docs/framework/cli.mdx +++ b/content/docs/framework/cli.mdx @@ -45,19 +45,19 @@ Builds every paywall, versions the changed ones, and leaves production alone. Re | Flag | What it does | | --- | --- | -| `--id ` | Limit to one paywall; repeatable. | +| `--id ` | Limit to these paywalls or funnels; repeatable or comma-separated. | | `--rename =` | Declare a directory rename so CI can resolve it. | -| `-m ` | Record why, shown with the version in the dashboard. | +| `-m ` | Recorded on the source commit — it only lands when the source actually changed. | A push refuses — before anything is written — when: -- a selected paywall has diagnostics (publishing is immutable; fix first), +- a selected paywall has diagnostics (publishing is immutable; fix first) — but a directory missing `app/index.tsx` or `config.ts` isn't a paywall yet, so it's skipped silently rather than blocking; `superwall dev` is where you see it, - a product in `config.ts` doesn't exist on the dashboard (every variable on it would be undefined on device), - a directory rename is unresolved (below). The first push binds each paywall — creating it on Superwall if needed — and records the binding in `superwall.lock`; commit that file. After that, push always updates the same paywall; no IDs ever appear in your code. -Every push also snapshots your `superwall/` source, so the dashboard can show and diff the code each version was built from. `.env`, `node_modules/`, and gitignored files never leave the machine. +Every push also snapshots your `superwall/` source, so the code each version was built from is recoverable. `.env`, `node_modules/`, `.git/`, and anything `superwall/.gitignore` lists never leave the machine. ### Renames @@ -75,7 +75,7 @@ Points production at a pushed version. Promote never rebuilds — it only moves | Flag | What it does | | --- | --- | -| `--id ` | Limit to one paywall. | +| `--id ` | Limit to one paywall or funnel. | | `--version`, `-v ` | Pick a specific version (with a single `--id`) — which is also the **rollback**. | ```sh @@ -85,7 +85,7 @@ superwall promote --id plus-upgrade --version 5 ## `superwall publish` -Push + promote in one step. Takes `-m `. Also warns about other paywalls that are pushed-but-not-live, so nothing ships half-forgotten. +Push + promote in one step. Takes `-m `, `--id`, and `--rename`. Also warns about other paywalls that are pushed-but-not-live, so nothing ships half-forgotten. ## Before pushing: create the products diff --git a/content/docs/framework/config.mdx b/content/docs/framework/config.mdx index fbda1c8e..8507ad0d 100644 --- a/content/docs/framework/config.mdx +++ b/content/docs/framework/config.mdx @@ -1,6 +1,6 @@ --- title: "Configuration" -description: "Everything definePaywall accepts — products, presentation, transitions, trial reminders, and the behavior settings that shape a paywall." +description: "Everything definePaywall accepts — products, background, transitions, trial reminders, and the behavior settings that shape a paywall." --- Every paywall declares itself in a required `config.ts`: its dashboard name, its products, and any behavior settings. This file is the whole truth for the paywall — nothing is inherited from anywhere else. @@ -36,36 +36,21 @@ export default definePaywall({ ...shared, name: "Pro", products: { … } }); | `transition` | `"push" \| "slide" \| "fade" \| "shift" \| "none"` or custom | `"push"` | Default page transition — see [Transitions](/framework/transitions). | | `queryState` | `boolean` | on when `checkout` is set | Keep the route stack and every `useQueryState` value in the page URL, so the flow resumes from any link. Never applies on a native host — see [Pages & navigation](/framework/navigation). | | `checkout` | mode or `{ mode, prefetch? }` | — | Sell on the web. Omit for native-only — see [Web checkout](/framework/web-checkout). | -| `presentation` | see [below](#presentation) | — | How the native SDK presents the paywall. | | `background` | `string` or `{ light, dark? }` | — | Background color painted behind the paywall while it loads and as the page background — see [below](#background). | -| `featureGating` | `"gated" \| "nonGated"` | `"nonGated"` | Whether users must pay to pass the placement. | | `introductoryOfferEligibility` | `"automatic" \| "alwaysEligible" \| "alwaysIneligible"` | `"automatic"` | Trial eligibility — `automatic` lets the store decide. | | `dismissOnPurchase` | `boolean` | — | Auto-dismiss the paywall after a completed purchase. | | `purchaseTimeoutMs` | `number` | — | Resolve a purchase as failed after this long with no result. | | `notifications` | `{ trialReminder }` | — | Trial-reminder notification — see [below](#trial-reminder-notifications). | | `localization` | `{ defaultLocale, messages? }` | `"en"` | Fallback locale; file-based catalogs need no config — see [Localization](/framework/localization). | | `scrollEnabled` | `boolean` | `true` | Whether the paywall scrolls. | -| `gameControllerEnabled` | `boolean` | — | Forward game-controller input to the paywall. | -| `onDeviceCacheEnabled` | `boolean` | `true` | Cache the paywall on device. | -There is deliberately **no identifier field**. The directory path is the paywall's identity, and the dashboard binding lives in `superwall.lock` — never in this file. See [Project structure](/framework/project-structure). +Presentation style, feature gating, and on-device caching are **dashboard settings**, not config keys — they live on the paywall in Superwall, not in `config.ts`. -## Presentation - -The `presentation` object controls how the native SDK presents the paywall over your app: - -```ts -presentation: { - style: "fullscreen" | "modal" | "push" | "drawer" | "popup" | "noAnimation", // default "fullscreen" - drawer: { height, cornerRadius }, // when style === "drawer" - popup: { width, height, cornerRadius }, // when style === "popup" -} -``` - -- **`style`** — `fullscreen` covers the screen, `modal` uses the platform's modal presentation, `push` pushes onto the navigation hierarchy, `drawer` rises from the bottom edge to the height you set, `popup` floats as a centered window, and `noAnimation` presents modally without animating. -- **`drawer` and `popup`** take sizing options that only apply to their matching style. + +There is deliberately **no identifier field**. The directory path is the paywall's identity, and the dashboard binding lives in `superwall.lock` — never in this file. See [Project structure](/framework/project-structure). + ## Background @@ -77,6 +62,8 @@ background: "#0d0f12" background: { light: "#ffffff", dark: "#0d0f12" } ``` +Colors are 6- or 8-digit hex (`#RRGGBB` or `#RRGGBBAA`). Shorthand like `#fff`, named colors, and `rgb()` are rejected at push. + It does two things from one value: native SDKs paint it behind the webview and derive the loading spinner from it, and the web document uses it as the page background — so the color a shopper sees while the paywall loads matches the color it settles on. It takes effect on the next `superwall publish`; when absent, paywalls fall back to the platform default. ## Products diff --git a/content/docs/framework/examples.mdx b/content/docs/framework/examples.mdx index 4cedb1f9..10e94ead 100644 --- a/content/docs/framework/examples.mdx +++ b/content/docs/framework/examples.mdx @@ -28,7 +28,7 @@ All examples are public at [github.com/superwall/superwall/tree/main/examples](h | Example | The one idea | | --- | --- | | [`multi-page`](https://github.com/superwall/superwall/tree/main/examples/multi-page) | `router.push`/`back`, the page stack, and chrome in `layout.tsx` that reads router state | -| [`transitions`](https://github.com/superwall/superwall/tree/main/examples/transitions) | All four built-ins plus a custom `zoom` — proof a transition is just CSS on two attributes | +| [`transitions`](https://github.com/superwall/superwall/tree/main/examples/transitions) | All five built-ins plus a custom `zoom` — proof a transition is just CSS on two attributes | | [`onboarding-quiz`](https://github.com/superwall/superwall/tree/main/examples/onboarding-quiz) | Answers decide where you land; the router carries no state (a plain module outside React does) | `onboarding-quiz`'s terminal page defends every read — a replayed page never crashes on a missing answer — and hardcodes step labels per page, because a branching flow's depth is not its step number. diff --git a/content/docs/framework/lifecycle.mdx b/content/docs/framework/lifecycle.mdx index 1ca1439a..7bd4344b 100644 --- a/content/docs/framework/lifecycle.mdx +++ b/content/docs/framework/lifecycle.mdx @@ -77,7 +77,7 @@ Don't use `@media (prefers-color-scheme: dark)` as the mechanism — it cannot s ## Dev vs device -The same paywall runs against a simulated host in `superwall dev` and the real SDK on device — purchases are simulated in one and real in the other, product variables are injected by the studio in one and delivered by the SDK on the other, and numeric variables arrive as **strings** on device. The full comparison table is in [The studio](/framework/studio). +The same paywall runs against a simulated host in `superwall dev` and the real SDK on device — purchases are simulated in one and real in the other, product variables are injected by the studio in one and delivered by the SDK on the other, and numeric **product** variables arrive as **strings** on device (device numerics like `daysSinceInstall` stay numbers). The full comparison table is in [The studio](/framework/studio). ## The platform stylesheet diff --git a/content/docs/framework/localization.mdx b/content/docs/framework/localization.mdx index c8dbe83e..1577b17f 100644 --- a/content/docs/framework/localization.mdx +++ b/content/docs/framework/localization.mdx @@ -67,7 +67,7 @@ const { t, locale, setLocale, locales } = useTranslation(); - **Never put a price in a catalog.** Prices are localized by the store — the SDK delivers the right currency and format for the user's region. Interpolate them: `"Subscribe · {price}"`. See [Products](/framework/products). - **No language picker on device.** The locale is the person's system setting; preview other locales with the studio's locale switcher. - **Copy expands.** German runs long — size nothing to fit English. -- Product `period` and `periodly` variables ("yearly" → "jährlich") localize automatically in 44 languages, independent of your catalogs. +- Product `period` and `periodly` variables ("yearly" → "jährlich") localize automatically in 44 locales, independent of your catalogs. - A single-locale paywall needs none of this — plain strings in JSX are fine until the second locale arrives. diff --git a/content/docs/framework/navigation.mdx b/content/docs/framework/navigation.mdx index 7f8ea182..2e3f6e93 100644 --- a/content/docs/framework/navigation.mdx +++ b/content/docs/framework/navigation.mdx @@ -39,13 +39,13 @@ router.back(); // one step back router.canGoBack(); // anything to go back to? router.dismiss(2); // back two steps router.dismissAll(); // back to the first page -router.dismissTo("goals"); // unwind to a page in the stack +router.dismissTo("goals"); // unwind to it (replaces current if not in the stack) router.name; // current page router.depth; // pages underneath (index = 0) ``` -If you've used expo-router, this is its API, method for method. Page names autocomplete and reject typos, thanks to the generated `superwall.d.ts` — one more reason to [commit it](/framework/project-structure). +If you've used expo-router, this is the same shape — minus `navigate`/`setParams`, plus `name` and `depth`. Page names autocomplete and reject typos, thanks to the generated `superwall.d.ts` — one more reason to [commit it](/framework/project-structure). A few rules make navigation feel right: @@ -98,9 +98,9 @@ The API is [nuqs](https://nuqs.dev)'s, so the parsers read the same: `parseAsStr [Web Funnels](/framework/web-funnels) has the full treatment — multi choice, inputs, branching, the URL budget. Three rules keep it honest: -- **Only what a page asks for is persisted.** The framework never decides what an answer is. Keys starting with `sw_` are reserved. +- **Only what a page asks for is persisted.** The framework never decides what an answer is. Keys starting with `sw_`, plus `platform` and `transport`, are reserved — the hook throws on them. - **Mind the URL budget.** About 2 kB is safe across every app and share sheet — keep keys short and values enumerable, and keep anything personal out of a URL. -- **The same code runs natively.** In an SDK webview there is no URL bar, so `useQueryState` is plain state shared across pages — the flow reads identically everywhere. `definePaywall({ queryState: true | false })` overrides the checkout default in either direction. +- **The same code runs natively.** In an SDK webview there is no URL bar, so `useQueryState` is plain state shared across pages — the flow reads identically everywhere. `definePaywall({ queryState: true | false })` overrides the checkout default on web builds; a native host forces it off regardless. ## Shared chrome diff --git a/content/docs/framework/project-structure.mdx b/content/docs/framework/project-structure.mdx index 54cbf6cc..6e41ed28 100644 --- a/content/docs/framework/project-structure.mdx +++ b/content/docs/framework/project-structure.mdx @@ -58,13 +58,13 @@ Renaming a paywall directory is safe: the next `push` notices and asks whether i ## Keep imports inside the project -Import from within `superwall/` or from packages listed in its `package.json`. An import that reaches outside — say `../../src/theme` — still builds on your machine, but the pushed source can no longer be rebuilt anywhere else, so the dashboard disables remote editing for that paywall and the push warns, naming each offender. +Import from within `superwall/` or from packages listed in its `package.json`. An import that reaches outside — say `../../src/theme` — still builds on your machine, but the pushed source can no longer be rebuilt anywhere else, so the push warns and names each offender, and the version is recorded as non-portable. Copy shared code into `superwall/components/` instead. Duplication here is deliberate: it's what keeps the project self-contained. ## `.env` -`superwall/.env` (with your app root's `.env` as a fallback) holds project credentials — `SUPERWALL_API_KEY` for CI pushes. It's gitignored and never leaves your machine: source pushes exclude `.env*`, `node_modules/`, `.superwall/`, and anything your `.gitignore` lists. +`superwall/.env` (with your app root's `.env` as a fallback) holds project credentials — `SUPERWALL_API_KEY` for CI pushes. It's gitignored and never leaves your machine: source pushes exclude `.env*`, `node_modules/`, `.superwall/`, `.git/`, and anything `superwall/.gitignore` lists. ## Funnels diff --git a/content/docs/framework/push-and-promote.mdx b/content/docs/framework/push-and-promote.mdx index 2e959fda..730b80b0 100644 --- a/content/docs/framework/push-and-promote.mdx +++ b/content/docs/framework/push-and-promote.mdx @@ -56,12 +56,12 @@ Deleting a paywall directory never blocks a push: the dashboard paywall keeps se ## Source snapshots -Every push also snapshots your `superwall/` source to Superwall, so the dashboard can show — and diff — the exact code each version was built from. The `-m "why"` note is recorded there too. +Every push also snapshots your `superwall/` source to Superwall, so the exact code each version was built from is recoverable. The `-m "why"` note is recorded on that source commit — it only lands when the source actually changed. -What never leaves your machine: `.env` files, `node_modules/`, `.superwall/`, and anything your `.gitignore` lists. +What never leaves your machine: `.env` files, `node_modules/`, `.superwall/`, `.git/`, and anything `superwall/.gitignore` lists. -If any import reaches outside the project directory, the push warns naming each offender, and the dashboard disables remote editing for that paywall — the pushed source can't be rebuilt elsewhere. Copy shared code into `superwall/components/` instead. See [Project structure](/framework/project-structure). +If any import reaches outside the project directory, the push warns naming each offender and records the version as non-portable — the pushed source can't be rebuilt elsewhere. Copy shared code into `superwall/components/` instead. See [Project structure](/framework/project-structure). ## `superwall promote` @@ -81,7 +81,7 @@ superwall promote --id plus-upgrade --version 5 Push + promote in one step. It also warns about other paywalls that are pushed-but-not-live, so nothing ships half-forgotten. -`publish` requires git — the source snapshot is part of every publish. +`push` and `publish` both require git — the source snapshot is part of each. ## CI diff --git a/content/docs/framework/quickstart.mdx b/content/docs/framework/quickstart.mdx index ac887c31..6119a3ea 100644 --- a/content/docs/framework/quickstart.mdx +++ b/content/docs/framework/quickstart.mdx @@ -9,7 +9,7 @@ This guide takes you from nothing to a live paywall: scaffold a project inside y You'll need: -- **Node 20+** (or Bun) and **git**. +- **Node 20.12+** (or Bun) and **git**. - A **Superwall account** with an application. The application must have the **headless paywalls** feature enabled — a push will tell you if it isn't. - The **Superwall CLI**: diff --git a/content/docs/framework/studio.mdx b/content/docs/framework/studio.mdx index afca5405..4fcafe41 100644 --- a/content/docs/framework/studio.mdx +++ b/content/docs/framework/studio.mdx @@ -18,18 +18,18 @@ Project problems — a stray file in `app/`, a duplicate route — print as warn ## What you can check -- **Devices** — iPhone SE through iPad Pro, plus Pixel. Switching devices also changes what the paywall sees as platform, model, and OS version, so platform-conditional code is testable too. -- **Light and dark** — the studio's theme toggle drives the same `dark` class the SDK stamps on device. Check both, always. +- **Devices** — iPhone SE through iPad Pro, plus Pixel, Galaxy and Desktop, and a free-resize responsive mode. Switching devices also changes what the paywall sees as platform, model, and OS version, so platform-conditional code is testable too. +- **Light and dark** — the studio's theme toggle drives the same `dark` class the framework stamps from what the device reports. Check both, always. - **Locale** — switch languages to proof every catalog. See [Localization](/framework/localization). - **Rotation** — portrait and landscape, live. See `useDevice().orientation` in the [hooks reference](/framework/hooks). - **Trial eligibility** — a toggle that flips the store's answer, so both versions of a trial paywall are one click apart. See [Free trials](/framework/trials). -- **Variables** — edit user attributes, device properties, placement params, and per-product variables live in the Variables panel. Values are seeded from your app's real sample data and products, so the preview reflects what production will see. See [Variables & personalization](/framework/variables). +- **Variables** — edit user attributes, device properties, placement params, and per-product variables live in the Variables panel. Values are seeded from your app's real sample data and products when you're logged in; without a login the panel falls back to built-in defaults. See [Variables & personalization](/framework/variables). ## Simulated outcomes -In dev, everything that would normally resolve from the host — purchases, restores, permission prompts, callbacks — prompts **you** to pick the outcome instead, so both branches of every flow are testable. Decline your own purchase to check the abandoned path; deny your own permission request to check the fallback copy. +In dev, most things that would normally resolve from the host — purchases, permission prompts, callbacks — prompt **you** to pick the outcome instead, so both branches of every flow are testable. Decline your own purchase to check the abandoned path; deny your own permission request to check the fallback copy. -Alongside it runs the **event log**: every message the paywall sends — haptics, page views, purchase attempts — as it happens. It's where you confirm that a tap fired its haptic, or that an action reached the host. +Actions the paywall sends to the host — `close()`, `openUrl()` — surface as toasts as they happen, so you can confirm one reached the host. Haptics, page views and purchase messages are deliberately silent. ## Dev vs device @@ -38,10 +38,11 @@ The same paywall runs against a simulated host in dev and the real SDK on device | | `superwall dev` | Real device | | --- | --- | --- | | Product variables | `undefined` until the studio injects your dashboard products | Delivered by the SDK | -| `purchase()` / `restore()` | Simulated — you pick the outcome | Real store | -| `close()`, `openUrl()`, haptics | Logged in the event log | Acted on by the host | +| `purchase()` | Simulated — you pick the outcome | Real store | +| `restore()` | Always succeeds | Real store | +| `close()`, `openUrl()` | Toast in the studio (haptics are silent) | Acted on by the host | | Permissions / callbacks | Studio prompts you | OS prompt / your app's code | -| Numeric variables | Numbers | **Strings** — always `Number()` first | +| Numeric **product** variables | Numbers | **Strings** — `Number()` before arithmetic | | Presentation (`paywall_open`) | Immediate | After preload, when actually shown | | Web checkout sheet | Not mounted — verify on a pushed version | Works | @@ -49,4 +50,4 @@ A published paywall never falls back to simulated data — the simulation exists ## The Push, Publish, and Promote buttons -The studio has buttons for the same operations as the CLI — good for quick iteration. For actually shipping, prefer the CLI: the buttons skip the diagnostics gate and the dashboard product check, can't resolve renames, and take no `-m` note. See [Push, promote & publish](/framework/push-and-promote). +The studio has buttons for the same operations as the CLI — good for quick iteration. Either way, pushing needs the `headless_paywalls` feature enabled on your Superwall application; it's a server-side flag, so ask your Superwall contact if a push comes back refused. For actually shipping, prefer the CLI: the buttons skip the diagnostics gate and the dashboard product check, can't resolve renames, and take no `-m` note. See [Push, promote & publish](/framework/push-and-promote). diff --git a/content/docs/framework/styling.mdx b/content/docs/framework/styling.mdx index 13176a02..20060a15 100644 --- a/content/docs/framework/styling.mdx +++ b/content/docs/framework/styling.mdx @@ -46,7 +46,7 @@ Read the current scheme in JavaScript with [`useColorScheme()`](/framework/hooks background: { light: "#ffffff", dark: "#0d0f12" } ``` -One value covers both sides of the load: it paints the page background, and it's sent to the native SDK, which paints the same color behind the webview and derives its loading spinner from it. Set it to whatever your page background is, and the paywall has no flash of a different color while it loads. +One value covers both sides of the load: it paints the page background, and it's sent to the native SDK, which paints the same color behind the webview and derives its loading spinner from it. Set it to whatever your page background is, and a native paywall has no flash of a different color while it loads. On the web the document paints the light color until React mounts, so a dark-mode visitor to a web funnel can still see one. ## Safe areas @@ -69,10 +69,10 @@ The system font stack is what makes a webview read as native. When a design call ## The platform stylesheet -Published paywalls receive a small Superwall-owned stylesheet at serve time, carrying platform-wide behavior like scroll control. It lands *before* your styles in the cascade, so your CSS always wins. Previews apply the same stylesheet, which is what makes local and published render identically. +Published paywalls receive a small Superwall-owned stylesheet at serve time, carrying platform-wide behavior like scroll control. It lands *before* your styles in the cascade, so your ordinary declarations win. A few platform rules are `!important` — `box-sizing` and `cursor` among them — and need `!important` of your own to override. Previews apply the same stylesheet, which is what makes local and published render identically. Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer. ## Check it in the studio -The [studio](/framework/studio) renders a paywall at device sizes with the controls a device would supply — a theme toggle for both color schemes, widths from 320px through tablet, and a trial-eligibility toggle. It's the fastest way to see a stylesheet behave under conditions your browser won't reproduce on its own. +The [studio](/framework/studio) renders a paywall at device sizes with the controls a device would supply — a theme toggle for both color schemes, presets from phone through desktop (or any width in responsive mode), and a trial-eligibility toggle. It's the fastest way to see a stylesheet behave under conditions your browser won't reproduce on its own. diff --git a/content/docs/framework/transitions.mdx b/content/docs/framework/transitions.mdx index 33a81683..c6cc172d 100644 --- a/content/docs/framework/transitions.mdx +++ b/content/docs/framework/transitions.mdx @@ -27,14 +27,14 @@ Going forward uses the *incoming* page's transition; going back uses the *leavin ## Tune the built-ins -Three CSS variables adjust timing and feel without replacing anything: +A handful of CSS variables adjust timing and feel without replacing anything. Set them on `[data-sw-route]`, not `:root` — the router writes `--sw-transition` inline on the routes container, and an inline declaration shadows `:root`: ```css -:root { +[data-sw-route] { --sw-transition: 500ms; /* duration */ - --sw-ease: cubic-bezier(0.28, 0.4, 0.08, 1); + --sw-ease: linear(…); /* a spring; cubic-bezier(0.28, 0.4, 0.08, 1) is the fallback */ --sw-stack-dim: 0.925; /* how much the page behind dims (the default) */ - --sw-transition-fade: 350ms; --sw-ease-fade: ease; /* fade runs on its own clock */ + --sw-transition-fade: 350ms; --sw-ease-fade: cubic-bezier(0.4, 0, 0.2, 1); --sw-transition-shift: 420ms; --sw-ease-shift: cubic-bezier(0.2, 0, 0, 1); } ``` @@ -80,7 +80,7 @@ The four phases cover both directions of travel: - **Always start `from` at `var(--sw-from-transform, )`** — and `--sw-from-filter` for filters. The router fills these with a page's live position when a navigation interrupts an animation, so a spammed button picks the page up where it stands instead of snapping. - **Wrap in `prefers-reduced-motion: no-preference`.** With reduced motion on, the router settles instantly and your animation never runs. -- **Duration comes from your CSS.** The page stays mounted exactly as long as its animation runs; don't declare a duration anywhere else. +- **Duration comes from your CSS.** The page stays mounted as long as its animation runs, capped at 5s (and falling back to 500ms when nothing measurable is declared); don't declare a duration anywhere else. - **Omit phases you don't want.** They don't animate — that's how `fade` crossfades one layer at a time. ## Bottom sheets over the flow diff --git a/content/docs/framework/troubleshooting.mdx b/content/docs/framework/troubleshooting.mdx index 2976840d..712be441 100644 --- a/content/docs/framework/troubleshooting.mdx +++ b/content/docs/framework/troubleshooting.mdx @@ -19,7 +19,7 @@ Your project's `package.json` has `"name": "superwall"`, which shadows the frame The project exists but its dependencies aren't installed, or `superwall` isn't among them. Run `bun add superwall` (or `npm install superwall`) inside the project directory. -### `These N products do not exist on Superwall` +### `This product / These N products do not exist on Superwall` A `config.ts` names a product identifier the dashboard has no product for. The push refuses because every variable on that product would be undefined on device. Either fix the identifier, or create the products — from the dashboard, or with `superwall products create` from the CLI. See [Products](/framework/products) and the [CLI reference](/framework/cli). @@ -35,6 +35,8 @@ Your account has several Superwall projects, and the command can't guess which o Publishing is immutable, so a paywall with diagnostics — a stray non-page file in `app/`, a duplicate route — refuses to push. The message names each file and where it belongs. These are the same warnings `superwall dev` prints, so you'll usually have seen them before push time. +A directory missing `app/index.tsx` or `config.ts` is the exception: it isn't a paywall yet, so push skips it silently instead of failing. If a paywall you expected didn't ship, check that both files exist — `superwall dev` lists what it found. + ### Rename ambiguity in CI A renamed paywall directory can't be resolved interactively in CI, so the push stops rather than creating a duplicate. Add the `--rename old=new` flag the error prints. See [Push, promote & publish](/framework/push-and-promote). @@ -45,9 +47,9 @@ Promote only moves the live pointer between pushed versions — there's nothing ### `superwall publish requires git` -The source snapshot is part of every publish. Install git. +The source snapshot is part of every push, so `push` and `publish` both need git — the message names `publish` whichever you ran. Install git. -### Not signed in +### `Pushing paywalls needs a Superwall account` Run `superwall login` once interactively, or set `SUPERWALL_API_KEY` (an `sk_…` key) in CI. `superwall dev` needs no login. diff --git a/content/docs/framework/variables.mdx b/content/docs/framework/variables.mdx index b8c9b196..9b938329 100644 --- a/content/docs/framework/variables.mdx +++ b/content/docs/framework/variables.mdx @@ -17,7 +17,7 @@ Three records, three sources: - **`device`** — filled in by the SDK: `platform`, `deviceModel`, `osVersion`, `appVersion`, `deviceLocale`, `regionCode`, `deviceCurrencyCode`, `subscriptionStatus`, `activeEntitlements`, `daysSinceInstall`, `totalPaywallViews`, and more. - **`user`** — whatever your app set via `setUserAttributes` (`user.firstName`, `user.plan`, …). -- **`params`** — whatever the placement was called with (`params.placementName`, plus anything the app passed alongside it). +- **`params`** — whatever the placement was called with (`params.event_name` is the placement's name; `$`-prefixed keys are SDK-set, and anything the app passed alongside comes through unprefixed). ```tsx const name = typeof user.firstName === "string" ? user.firstName : undefined; @@ -31,7 +31,7 @@ const name = typeof user.firstName === "string" ? user.firstName : undefined; All three records are filled in by the host — your paywall controls none of them, so every read needs a fallback: - For **`device`** fields, `?? "—"` (or any sensible default) suffices — the SDK guarantees the shape, just not that a value has arrived yet. -- For **`user`** and **`params`**, the host controls the *type* too, so check it before using it: `typeof params.placementName === "string"`. An attribute your app sets as a number today might be a string tomorrow, and the paywall must not crash either way. +- For **`user`** and **`params`**, the host controls the *type* too, so check it before using it: `typeof params.event_name === "string"`. An attribute your app sets as a number today might be a string tomorrow, and the paywall must not crash either way. `device.isSandbox` is a string, not a boolean. Compare it as one. From b65c2481c861ec9ff22eb0c679b6f0a08bf5123e Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Tue, 25 Aug 2026 18:10:01 +0200 Subject: [PATCH 6/7] fix: tweaks --- content/docs/framework/products.mdx | 10 +++++----- content/docs/framework/purchases.mdx | 19 ++++++++++--------- content/docs/framework/trials.mdx | 13 ++++++++----- content/docs/framework/web-checkout.mdx | 12 ++++++++---- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/content/docs/framework/products.mdx b/content/docs/framework/products.mdx index c221c2ac..3bb16b51 100644 --- a/content/docs/framework/products.mdx +++ b/content/docs/framework/products.mdx @@ -34,7 +34,7 @@ Your code only ever speaks in references — `getProduct("annual")`, `purchase(" ### Web and Stripe products -Web paywalls sell through Stripe, and the Stripe price lives inside the identifier — no separate mapping. The format is `{environment}:{priceId}:{offer}`: +Web paywalls sell through Stripe, and the Stripe price lives inside the identifier — no separate mapping. The format is `{environment}:{priceId}:{offer}`, where `{environment}` is exactly `test` or `live`: ```ts products: { @@ -63,14 +63,14 @@ annual?.variables.trialPeriodDays References are typed against your config, so a typo in `getProduct("anual")` is a compile error, not a runtime surprise. -Everything on `variables`, all optional: +The variables you'll reach for, all optional: | Group | Variables | | --- | --- | | Price | `price`, `rawPrice`, `currencyCode`, `currencySymbol` | -| Period | `period` ("year"), `periodly` ("yearly"), `periodDays`, `periodWeeks`, `periodMonths`, `periodYears` | +| Period | `period` ("year"), `periodAlt`, `localizedPeriod`, `periodly` ("yearly"), `periodDays`, `periodWeeks`, `periodMonths`, `periodYears` | | Per-interval price | `dailyPrice`, `weeklyPrice`, `monthlyPrice`, `yearlyPrice` | -| Trial | `trialPeriodDays`, `trialPeriodWeeks`, `trialPeriodMonths`, `trialPeriodYears`, `trialPeriodPrice`, `trialPeriodText` ("7-day"), `trialPeriodEndDate` ("Jul 23, 2026"), per-interval trial prices | +| Trial | `trialPeriodDays`, `trialPeriodWeeks`, `trialPeriodMonths`, `trialPeriodYears`, `trialPeriodPrice`, `rawTrialPeriodPrice`, `trialPeriodText` ("7-day"), `trialPeriodEndDate` ("Jul 23, 2026"), per-interval trial prices | | Locale | `locale`, `languageCode` | | State | `identifier`, `isSubscribed` | @@ -117,7 +117,7 @@ The `product-selection` [example](/framework/examples) shows the full pattern: a ## Create the products on the dashboard -A push refuses if `config.ts` names a product the dashboard doesn't have. Create products in the dashboard, or straight from the CLI: +A push refuses if `config.ts` names a product the dashboard doesn't have — Stripe identifiers included. Store products can be created straight from the CLI; Stripe products are imported into the dashboard from Stripe instead, and the flags below don't apply to them. ```bash superwall products create pro_5999_year \ diff --git a/content/docs/framework/purchases.mdx b/content/docs/framework/purchases.mdx index 0db6278f..993c9aa5 100644 --- a/content/docs/framework/purchases.mdx +++ b/content/docs/framework/purchases.mdx @@ -29,17 +29,17 @@ const haptics = useHaptics(); | Status | Meaning | Respond by | | --- | --- | --- | | `completed` | The sale went through | `haptics.success()`; the SDK dismisses the paywall if configured | -| `abandoned` | The user closed the store sheet | Treat as an ordinary outcome — most people who open a sheet close it. This is the only place *this paywall's own* declined offer is visible: show a last-chance offer, or nothing | -| `failed` | No transaction happened — `reason` is `"timeout"` or `"superseded"` (a retry or re-presentation replaced this attempt) | Usually nothing; `haptics.error()` at most | +| `abandoned` | The user closed the store sheet | Treat as an ordinary outcome — most people who open a sheet close it: show a last-chance offer, or nothing | +| `failed` | No transaction happened. On a store purchase `reason` is `"timeout"` or `"superseded"` (a retry or re-presentation replaced this attempt); web checkout failures carry no `reason` | Usually nothing; `haptics.error()` at most | ### Reacting to abandoned -`abandoned` is the one outcome only this paywall can see — the SDK reports transactions, not declines. That makes it the hook for a last-chance offer: +`abandoned` is what your `purchase()` call resolves with when the user closes the store sheet; the same decline also arrives as a `transaction_abandon` event. Either makes a good hook for a last-chance offer: ```tsx const result = await purchase(selected); if (result.status === "abandoned") { - router.push("offer", { transition: "sheet" }); + router.push("offer", { transition: "sheet" }); // "sheet" is a transition you define in CSS } ``` @@ -51,22 +51,23 @@ The `abandonment-offer` [example](/framework/examples) shows the full pattern. purchase(reference, { shouldDismiss?, timeoutMs? }) ``` -Both default to what [`config.ts`](/framework/config) declares (`dismissOnPurchase`, `purchaseTimeoutMs`). +Both default to what [`config.ts`](/framework/config) declares (`dismissOnPurchase`, `purchaseTimeoutMs`). Neither has a built-in default: with no timeout set anywhere, a purchase waits indefinitely and `"timeout"` never occurs. ## The two channels -Your `purchase()` call is one channel. The SDK reporting on its own is the other — and it reports transactions **whoever started them**. A successful restore arrives as a `transaction_complete` event with no purchase call in sight. +Your `purchase()` call is one channel. The SDK reporting on its own is the other — it reports what happened, whether or not this paywall started it: a purchase completing, a trial beginning, a sheet being abandoned. ```tsx // this paywall's own attempt const result = await purchase("annual"); -// anything the SDK reports — purchase, restore, trial start +// anything the SDK reports, whoever started it useSuperwallEvent("transaction_complete", () => haptics.success()); +useSuperwallEvent("transaction_abandon", () => {}); useSuperwallEvent("freeTrial_start", () => {}); ``` -Drive *this paywall's* flow from the awaited result; use events for side effects that should fire on any transaction, however it started. The `purchase-states` [example](/framework/examples) shows both channels side by side — and it's the one example that demonstrates the full haptic vocabulary (`success()` and `error()` keyed to outcomes). +Drive *this paywall's* flow from the awaited result; use events for side effects that should fire on any transaction, however it started. The `purchase-states` [example](/framework/examples) shows both channels side by side, with [`useHaptics()`](/framework/hooks#usehaptics) keyed to each outcome. See [Lifecycle & events](/framework/lifecycle) for the full event list. @@ -82,7 +83,7 @@ const { restore } = useActions(); ``` -`restore()` is fire-and-forget — there is no result to await. Success surfaces as a `transaction_complete` event or a dismissed paywall. Every store paywall should offer restore — App Review expects it. +`restore()` is fire-and-forget — there is no result to await, and no event you can subscribe to today. Success surfaces as a dismissed paywall. Every store paywall should offer restore — App Review expects it. ## Selling beyond the App Store diff --git a/content/docs/framework/trials.mdx b/content/docs/framework/trials.mdx index d272d40b..01a1098a 100644 --- a/content/docs/framework/trials.mdx +++ b/content/docs/framework/trials.mdx @@ -72,17 +72,20 @@ notifications: { `title`, `subtitle`, and `body` accept message keys (resolved through `t()` — see [Localization](/framework/localization)) or literal copy. -For full control, pass a function instead. It receives `{ trialEndDate, product, t, locale }` and returns `{ title, body, delayMs }` — or `null` to skip the notification entirely: +For full control, pass a function instead. It receives `{ trialEndDate, productIdentifier, product, t, locale }` and returns `{ title, subtitle?, body, delayMs }` — or `null` to skip the notification entirely: ```ts notifications: { - trialReminder: ({ trialEndDate, t }) => - trialEndDate - ? { title: t("reminder.title"), body: t("reminder.body"), delayMs: 0 } - : null, + trialReminder: ({ trialEndDate, t }) => ({ + title: t("reminder.title"), + body: t("reminder.body"), + delayMs: trialEndDate.getTime() - Date.now() - 24 * 60 * 60 * 1000, + }), }, ``` +`delayMs` is measured from now, and a value of `0` or less is skipped rather than fired immediately — compute it from `trialEndDate`, which is always supplied. + The `trial-reminders` [example](/framework/examples) shows both forms. diff --git a/content/docs/framework/web-checkout.mdx b/content/docs/framework/web-checkout.mdx index 0acc8ae2..8bb62132 100644 --- a/content/docs/framework/web-checkout.mdx +++ b/content/docs/framework/web-checkout.mdx @@ -11,6 +11,8 @@ checkout: "sheet", Native hosts ignore it — drop the same paywall into your iOS app and it buys through the App Store. Your components don't change, and neither does `purchase()`. +One exception: in `external` mode the page navigates away to the hosted checkout page, so `purchase()` never resolves. Call it, but don't `await` it or branch on its result. + This page covers the framework side: config, modes, and prefetching. Stripe keys, web apps, products, and campaigns are set up in the dashboard — see the [Web Checkout](/web-checkout) section for that half. @@ -23,11 +25,11 @@ This page covers the framework side: config, modes, and prefetching. Stripe keys | `applePay` | Straight to Apple Pay where available, sheet as fallback | Apple-Pay-heavy audiences | | `external` | Superwall's hosted checkout page, then back | You want zero payment UI in the paywall | -Only `sheet` and `applePay` add payment UI to the paywall (about 85 kB); `external` adds nothing. +Only `sheet` and `applePay` add payment UI to the paywall; `external` adds nothing. Stripe's own scripts load at runtime from `js.stripe.com` rather than being bundled. ## Products -Web paywalls sell Stripe products, declared with the price inside the identifier — `{environment}:{priceId}:{offer}`: +Web paywalls sell Stripe products, declared with the price inside the identifier — `{environment}:{priceId}:{offer}`, where `{environment}` is exactly `test` or `live`: ```ts products: { @@ -53,14 +55,14 @@ The web sheet does not set `isPurchasing` — react to the awaited result, which Creating a checkout session takes a network round-trip. Prefetching does it before the tap, so the sheet opens with nothing to wait for. -**Automatic:** every `sheet`/`applePay` paywall warms its first Stripe product on load. Steer it in config: +**Automatic:** a `sheet` paywall warms one Stripe product on load; `applePay` warms every Stripe product on the paywall, up to ten. Steer it in config: ```ts checkout: { mode: "sheet", prefetch: "pro" } // which product warms first checkout: { mode: "sheet", prefetch: false } // disable auto-prefetch ``` -**On selection — do this whenever there's a product selector.** The default warms one plan; prefetch the selected one so whichever plan is on screen opens instantly: +**On selection — do this whenever there's a product selector.** With `sheet`, only one plan is warmed; prefetch the selected one so whichever plan is on screen opens instantly: ```tsx import { usePurchase, type ProductReference } from "superwall/hooks"; @@ -83,6 +85,8 @@ It takes no colors, fonts, or spacing from the page around it, and there's no pr `superwall dev` previews the flow and the copy, but it does not mount the payment sheet. Push and open the live URL to verify the checkout itself — see [Push, promote & publish](/framework/push-and-promote). +Two things gate that push: the Stripe product must already be imported into your Superwall dashboard (push validates every declared identifier, Stripe ones included), and the application needs the `headless_paywalls` feature enabled — see the [CLI reference](/framework/cli). + ## A full web funnel The `web-funnel` [example](/framework/examples) is the reference: question steps as pages, a typed plan selector with on-selection prefetch, then `purchase(reference)` — the whole flow in one paywall. Because `checkout` is set, the flow's step and answers live in the page URL, so it resumes from any link — in Safari after an in-app browser, or back from hosted checkout. Keep every answer in `useQueryState` — [Web Funnels](/framework/web-funnels) is the guide. From 7e7e7718f1dfedfdb9a2d783faefbbb786545040 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Tue, 25 Aug 2026 15:20:04 -0500 Subject: [PATCH 7/7] docs(framework): answer what/why up front, fix claim errors, drop em dashes Review pass over the new Framework section. Overview: open by naming the alternative (the visual editor) and the job this does, instead of leading with a feature list. Adds a "which should I use" comparison. The section previously never mentioned the editor at all. Claim fixes, each verified against superwall/superwall and the iOS SDK: - Restore does not fire transaction_complete. iOS sends restoreComplete / transactionRestore, neither of which the framework protocol models, so the typed event map cannot subscribe to them. lifecycle.mdx and actions.mdx claimed otherwise while purchases.mdx had it right; made purchases.mdx canonical. Note the dev mock does answer a restore with transaction_complete, so this works in the studio and no-ops on device. - transaction_fail is unmodeled, so a declined card leaves purchase() pending rather than resolving failed. Documented, with the advice to set purchaseTimeoutMs on any paywall gating UI on the awaited result. - --sw-background was undocumented on the styling page. Nothing derives it from config.background; every example and the CLI scaffold set it by hand, so following the page as written left system Canvas painted over the configured color. navigation.mdx framed it as layout-conditional, which is true of --sw-routes-height only; split the two. - presentation, featureGating and onDeviceCacheEnabled were called "not config keys". They are keys, accepted by the type and never read, so setting them type-checks, builds, pushes without warning and does nothing. Reworded to say so. - superseded narrowed to a newer purchase for the same product reference. - Portability warning names the first five offenders, then counts the rest, and offers a package registry as a second remedy. - Declared the missing haptics binding in the restore snippet. Prose: 385 em dashes down to 30, all of which are code samples, paywall copy inside examples, table "none" cells, or a JSX fallback glyph. Build: raise the prerender heap from 5120 to 8192. The section pushed the crawl past the old ceiling; branch HEAD passed only marginally, and 6144 is the floor. 719 pages prerendered, 64 tests pass. Findings that look like framework rather than docs bugs, including the restore mock divergence and a missing *.m4a module declaration, are on PR #272. Co-Authored-By: Claude Opus 5 --- content/docs/framework/actions.mdx | 22 +++---- content/docs/framework/assets.mdx | 18 +++--- content/docs/framework/cli.mdx | 36 ++++++------ content/docs/framework/config.mdx | 38 ++++++------ content/docs/framework/examples.mdx | 32 +++++----- content/docs/framework/hooks.mdx | 36 ++++++------ content/docs/framework/index.mdx | 61 ++++++++++++++------ content/docs/framework/lifecycle.mdx | 26 ++++----- content/docs/framework/localization.mdx | 30 +++++----- content/docs/framework/navigation.mdx | 47 +++++++-------- content/docs/framework/products.mdx | 24 ++++---- content/docs/framework/project-structure.mdx | 20 +++---- content/docs/framework/purchases.mdx | 19 +++--- content/docs/framework/push-and-promote.mdx | 22 +++---- content/docs/framework/quickstart.mdx | 22 +++---- content/docs/framework/studio.mdx | 30 +++++----- content/docs/framework/styling.mdx | 30 +++++++--- content/docs/framework/transitions.mdx | 28 ++++----- content/docs/framework/trials.mdx | 22 +++---- content/docs/framework/troubleshooting.mdx | 26 ++++----- content/docs/framework/variables.mdx | 24 ++++---- content/docs/framework/web-checkout.mdx | 34 +++++------ content/docs/framework/web-funnels.mdx | 18 +++--- package.json | 2 +- 24 files changed, 356 insertions(+), 311 deletions(-) diff --git a/content/docs/framework/actions.mdx b/content/docs/framework/actions.mdx index 804405ef..8fc4a97b 100644 --- a/content/docs/framework/actions.mdx +++ b/content/docs/framework/actions.mdx @@ -1,6 +1,6 @@ --- title: "Actions" -description: "Close the paywall, restore purchases, open links, request OS permissions, and call back into your app — everything a paywall asks its host to do." +description: "Close the paywall, restore purchases, open links, request OS permissions, and call back into your app, everything a paywall asks its host to do." --- A paywall runs inside your app, and some things only the host can do: dismiss the paywall, open a link, prompt for a permission, run your app's code. All of it goes through `useActions()`: @@ -15,21 +15,21 @@ const { close, restore, openUrl, requestPermission, requestCallback } = useActio | Action | Use it for | | --- | --- | -| `close()` | Closing the paywall — the X button. Closing is not navigation. | -| `restore()` | Restore purchases. Fire-and-forget: success arrives as a `transaction_complete` event or a dismissed paywall — there is no return value to await. See [Purchases](/framework/purchases). | +| `close()` | Closing the paywall. The X button. Closing is not navigation. | +| `restore()` | Restore purchases. Fire-and-forget. There is no restore-result event and no value to await; success surfaces as a dismissed paywall. See [Purchases](/framework/purchases#restore). | | `openUrl(url)` | Terms, privacy, any link. **Always this, never ``.** | | `openExternalUrl(url)` | Open in the system browser instead of in-app. | | `openDeepLink(link)` | Deep link into the app. | -| `customPlacement(name, params?)` | Fire a Superwall placement — which can present another paywall. | +| `customPlacement(name, params?)` | Fire a Superwall placement, which can present another paywall. | | `requestPermission(type)` | OS permission prompt. Resolves `"granted" \| "denied" \| "unsupported"`. | | `requestCallback(name, options?)` | Run **your app's** code and await its answer. Resolves `{ status: "success" \| "failure", data? }`. | | `requestStoreReview("in-app" \| "external")` | Store review prompt. | -Links go through `openUrl`, never an ``. Inside a webview, an anchor either does nothing or navigates the paywall away from itself — `openUrl` hands the URL to the host so it opens the way the platform expects. +Links go through `openUrl`, never an ``. Inside a webview, an anchor either does nothing or navigates the paywall away from itself. `openUrl` hands the URL to the host so it opens the way the platform expects. -Closing works the same way: the paywall lives on a navigation stack of its own pages, but *leaving* the paywall isn't a navigation — it's `close()`. See [Pages & navigation](/framework/navigation). +Closing works the same way: the paywall lives on a navigation stack of its own pages, but *leaving* the paywall isn't a navigation. It's `close()`. See [Pages & navigation](/framework/navigation). ## Permissions @@ -40,9 +40,9 @@ const status = await requestPermission("notification"); Permission types: `notification`, `camera`, `microphone`, `location`, `background_location`, `contacts`, `read_images`, `read_video` (Android only), `tracking`. -## Callbacks — ask your app a question +## Callbacks: ask your app a question -A callback runs code *in your app* and hands the answer back to the paywall — anything the paywall cannot know on its own: does this account exist, is this referral code valid, what did the user pick during signup. +A callback runs code *in your app* and hands the answer back to the paywall, anything the paywall cannot know on its own: does this account exist, is this referral code valid, what did the user pick during signup. ```tsx const result = await requestCallback<{ exists: boolean }>("checkAccount"); @@ -52,18 +52,18 @@ if (result.status === "success" && result.data?.exists) { } ``` -Type the answer with a claim, as above — the generic is your statement of what the app returns. +Type the answer with a claim, as above. The generic is your statement of what the app returns. ### Permission vs callback A **permission** asks the OS; a **callback** asks your app. Both resolve from code the paywall does not control, which shapes how you use them: - **Show something while they run.** The OS prompt or your app's code takes as long as it takes. -- **Treat a denial as an ordinary outcome**, not an error. A user who declines notifications is still a user — design the path that continues without. +- **Treat a denial as an ordinary outcome**, not an error. A user who declines notifications is still a user, design the path that continues without. ## In development -In `superwall dev`, actions don't reach a real host — they're logged in the studio's event log, and permission, callback, and purchase requests prompt **you** to pick the outcome. That makes both branches of every flow testable before a device ever sees it. See [The studio](/framework/studio). +In `superwall dev`, actions don't reach a real host. They're logged in the studio's event log, and permission, callback, and purchase requests prompt **you** to pick the outcome. That makes both branches of every flow testable before a device ever sees it. See [The studio](/framework/studio). The permissions example shows `requestPermission` and `requestCallback` side by side, with a denial treated as an outcome rather than an error. See [Examples](/framework/examples). diff --git a/content/docs/framework/assets.mdx b/content/docs/framework/assets.mdx index bc41d378..5512dc06 100644 --- a/content/docs/framework/assets.mdx +++ b/content/docs/framework/assets.mdx @@ -17,7 +17,7 @@ superwall/ ``` -Every asset belongs in an `assets/` directory — `superwall/assets/` for shared files, `superwall/paywalls//assets/` for one paywall's own. If a large asset lives anywhere else, the build fails and names the file. +Every asset belongs in an `assets/` directory, `superwall/assets/` for shared files, `superwall/paywalls//assets/` for one paywall's own. If a large asset lives anywhere else, the build fails and names the file. ## Use an image @@ -31,7 +31,7 @@ import badge from "../assets/badge.png"; // this paywall's own ``` -CSS `url()` works the same way. Imports typecheck because of the generated `superwall.d.ts` — one more reason to [commit it](/framework/project-structure). +CSS `url()` works the same way. Imports typecheck because of the generated `superwall.d.ts`, one more reason to [commit it](/framework/project-structure). Supported out of the box: @@ -47,7 +47,7 @@ Supported out of the box: ## How hosting works -You never choose where an asset is served from — the build decides, and nothing about your code changes either way: +You never choose where an asset is served from. The build decides, and nothing about your code changes either way: - **Video, audio, and fonts** are always served from Superwall's CDN, whatever their size. Video streams properly instead of being carried by the paywall, and one upload is reused across every version of every paywall. - **Images** embed in the paywall when small and move to the CDN when large. @@ -73,7 +73,7 @@ A relative-path `@font-face` is the whole setup: :root { --sans: "Manrope Custom", ui-sans-serif, system-ui, sans-serif; } ``` -Google Fonts go in a CSS `@import` at the top of the stylesheet — never a React-rendered `` tag. The stylesheet ships in the page itself, so the browser finds the `@import` immediately; a rendered `` waits for JavaScript to run first, and the text flashes. +Google Fonts go in a CSS `@import` at the top of the stylesheet, never a React-rendered `` tag. The stylesheet ships in the page itself, so the browser finds the `@import` immediately; a rendered `` waits for JavaScript to run first, and the text flashes. The custom-fonts [example](/framework/examples) shows a local file and a Google Fonts import side by side. @@ -107,16 +107,16 @@ const { RiveComponent } = useRive({ src: smiley, stateMachines: "State Machine 1 ``` -Rive `fetch`es its WebAssembly engine from a CDN by default, and a published paywall's CSP allows `connect-src` only to Superwall's own origins — so that fetch never lands. Bundle the wasm with the `?url` import as above, and null the fallback so a failure stays loud rather than silently retrying a CDN that will never answer. (Stylesheets, fonts, images and media from `https:` are fine — which is why the Google Fonts `@import` above works.) +Rive `fetch`es its WebAssembly engine from a CDN by default, and a published paywall's CSP allows `connect-src` only to Superwall's own origins, so that fetch never lands. Bundle the wasm with the `?url` import as above, and null the fallback so a failure stays loud rather than silently retrying a CDN that will never answer. (Stylesheets, fonts, images and media from `https:` are fine, which is why the Google Fonts `@import` above works.) -Also pass the file's **real state-machine name** — naming one that doesn't exist leaves a blank canvas and no error. The with-rive [example](/framework/examples) is the reference. +Also pass the file's **real state-machine name**, naming one that doesn't exist leaves a blank canvas and no error. The with-rive [example](/framework/examples) is the reference. ## Multi-page flows -Nothing to do — while the user is on the current page, the next pages' images, video, and fonts warm automatically. Every hosted asset a version references is also stamped into the paywall's pre-cache manifest on promote, so the SDK caches it on device before the paywall opens. +Nothing to do, while the user is on the current page, the next pages' images, video, and fonts warm automatically. Every hosted asset a version references is also stamped into the paywall's pre-cache manifest on promote, so the SDK caches it on device before the paywall opens. ## Keep it light -- Big imagery is fine — it's served from the CDN and cached, not carried by the paywall itself. -- Pushing files over 50 MB warns — every future clone of the source pays for them — but nothing is capped. +- Big imagery is fine. It's served from the CDN and cached, not carried by the paywall itself. +- Pushing files over 50 MB warns (every future clone of the source pays for them), but nothing is capped. diff --git a/content/docs/framework/cli.mdx b/content/docs/framework/cli.mdx index 8bff7b79..f3202c58 100644 --- a/content/docs/framework/cli.mdx +++ b/content/docs/framework/cli.mdx @@ -1,9 +1,9 @@ --- title: "CLI Reference" -description: "Every superwall command — create, dev, push, promote, publish — with flags, auth, and the checks that run before anything ships." +description: "Every superwall command (create, dev, push, promote, publish) with flags, auth, and the checks that run before anything ships." --- -The CLI has git semantics on purpose: **push saves, promote ships.** Every push mints a sealed version; nothing users see changes until promote points production at it. This page is the command reference — [Push, promote & publish](/framework/push-and-promote) explains the model. +The CLI has git semantics on purpose: **push saves, promote ships.** Every push mints a sealed version; nothing users see changes until promote points production at it. This page is the command reference. [Push, promote & publish](/framework/push-and-promote) explains the model. ```sh superwall create # scaffold superwall/ inside your app @@ -18,7 +18,7 @@ The scaffolded package scripts mirror these (`dev`, `push`, `promote`, `ship`). ## Auth -Run `superwall login` once interactively. In CI, set `SUPERWALL_API_KEY` (an `sk_…` key) — the project's `.env` is the usual home for it. `dev` needs no login. +Run `superwall login` once interactively. In CI, set `SUPERWALL_API_KEY` (an `sk_…` key). The project's `.env` is the usual home for it. `dev` needs no login. ## `superwall create` @@ -30,14 +30,14 @@ Scaffolds a complete project: the directory skeleton, a starter paywall, depende ## `superwall dev` -Hosts [the studio](/framework/studio) for the project — or several at once with a glob (`superwall dev examples/*`). Regenerates `superwall.d.ts` first, so route and product types are always current. +Hosts [the studio](/framework/studio) for the project, or several at once with a glob (`superwall dev examples/*`). Regenerates `superwall.d.ts` first, so route and product types are always current. | Flag | What it does | | --- | --- | -| `--port`, `-p` | Port, default `6100` — moves to the next free port if taken. | +| `--port`, `-p` | Port, default `6100`, moves to the next free port if taken. | | `--host` | Bind address, for previewing from another device. | -Project problems (stray files in `app/`, duplicate routes) print as warnings here — the same ones that block a push, so fix them as they appear. +Project problems (stray files in `app/`, duplicate routes) print as warnings here, the same ones that block a push, so fix them as they appear. ## `superwall push` @@ -47,36 +47,36 @@ Builds every paywall, versions the changed ones, and leaves production alone. Re | --- | --- | | `--id ` | Limit to these paywalls or funnels; repeatable or comma-separated. | | `--rename =` | Declare a directory rename so CI can resolve it. | -| `-m ` | Recorded on the source commit — it only lands when the source actually changed. | +| `-m ` | Recorded on the source commit, it only lands when the source actually changed. | -A push refuses — before anything is written — when: +A push refuses, before anything is written, when: -- a selected paywall has diagnostics (publishing is immutable; fix first) — but a directory missing `app/index.tsx` or `config.ts` isn't a paywall yet, so it's skipped silently rather than blocking; `superwall dev` is where you see it, +- a selected paywall has diagnostics (publishing is immutable; fix first), but a directory missing `app/index.tsx` or `config.ts` isn't a paywall yet, so it's skipped silently rather than blocking; `superwall dev` is where you see it, - a product in `config.ts` doesn't exist on the dashboard (every variable on it would be undefined on device), - a directory rename is unresolved (below). -The first push binds each paywall — creating it on Superwall if needed — and records the binding in `superwall.lock`; commit that file. After that, push always updates the same paywall; no IDs ever appear in your code. +The first push binds each paywall, creating it on Superwall if needed, and records the binding in `superwall.lock`; commit that file. After that, push always updates the same paywall; no IDs ever appear in your code. Every push also snapshots your `superwall/` source, so the code each version was built from is recoverable. `.env`, `node_modules/`, `.git/`, and anything `superwall/.gitignore` lists never leave the machine. ### Renames -Renaming a paywall directory is detected, never guessed. Interactively, push asks whether the unfamiliar directory is a rename (keeping the live paywall attached) or a new paywall. In CI, declare it — anything unresolved stops the push rather than creating a duplicate: +Renaming a paywall directory is detected, never guessed. Interactively, push asks whether the unfamiliar directory is a rename (keeping the live paywall attached) or a new paywall. In CI, declare it, anything unresolved stops the push rather than creating a duplicate: ```sh superwall push --rename plus-upgrade=pro-upgrade ``` -Deleting a paywall directory never blocks a push — the dashboard paywall keeps serving, and restoring the directory re-binds it. +Deleting a paywall directory never blocks a push. The dashboard paywall keeps serving, and restoring the directory re-binds it. ## `superwall promote` -Points production at a pushed version. Promote never rebuilds — it only moves the live pointer. +Points production at a pushed version. Promote never rebuilds, it only moves the live pointer. | Flag | What it does | | --- | --- | | `--id ` | Limit to one paywall or funnel. | -| `--version`, `-v ` | Pick a specific version (with a single `--id`) — which is also the **rollback**. | +| `--version`, `-v ` | Pick a specific version (with a single `--id`), which is also the **rollback**. | ```sh superwall promote --id plus-upgrade --version 5 @@ -89,7 +89,7 @@ Push + promote in one step. Takes `-m `, `--id`, and `--rename`. Also warn ## Before pushing: create the products -A push refuses if a `config.ts` names a product the dashboard doesn't have. The fix is a command away — the same CLI writes products directly: +A push refuses if a `config.ts` names a product the dashboard doesn't have. The fix is a command away. The same CLI writes products directly: ```sh superwall entitlements list --json # grab the NUMERIC entitlement id @@ -99,14 +99,14 @@ superwall products create pro_3999_year \ --trial-days 7 --entitlement --json ``` -- `--entitlement` takes the **numeric id** (`55688`), not the identifier (`pro`) — the identifier fails with a decode error. +- `--entitlement` takes the **numeric id** (`55688`), not the identifier (`pro`), the identifier fails with a decode error. - Pass `--project` explicitly when your account has several, or the command errors with "Multiple projects found". - `--price` is major units (`39.99`); `--period` is `day|week|month|year`; `--trial-days` sets the intro offer. - `--dry-run` confirms the target before writing anything. ## Two gates worth checking early -- **Headless paywalls must be enabled on the application** — otherwise every push fails with "Headless paywalls are not enabled for this application". It's a server-side feature flag; check with `superwall apps list --json` and look for `headless_paywalls` in `features_enabled`. Contact us to have it turned on. -- **One broken surface blocks the whole push.** A leftover scaffold aimed at a nonexistent product stops everything — push what you built with repeated `--id` flags instead of touching unrelated directories. +- **Headless paywalls must be enabled on the application**, otherwise every push fails with "Headless paywalls are not enabled for this application". It's a server-side feature flag; check with `superwall apps list --json` and look for `headless_paywalls` in `features_enabled`. Contact us to have it turned on. +- **One broken surface blocks the whole push.** A leftover scaffold aimed at a nonexistent product stops everything, push what you built with repeated `--id` flags instead of touching unrelated directories. For the full error-message-to-fix table, see [Troubleshooting](/framework/troubleshooting). diff --git a/content/docs/framework/config.mdx b/content/docs/framework/config.mdx index 8507ad0d..2ddfbbd5 100644 --- a/content/docs/framework/config.mdx +++ b/content/docs/framework/config.mdx @@ -1,9 +1,9 @@ --- title: "Configuration" -description: "Everything definePaywall accepts — products, background, transitions, trial reminders, and the behavior settings that shape a paywall." +description: "Everything definePaywall accepts, products, background, transitions, trial reminders, and the behavior settings that shape a paywall." --- -Every paywall declares itself in a required `config.ts`: its dashboard name, its products, and any behavior settings. This file is the whole truth for the paywall — nothing is inherited from anywhere else. +Every paywall declares itself in a required `config.ts`: its dashboard name, its products, and any behavior settings. This file is the whole truth for the paywall. Nothing is inherited from anywhere else. ```ts import { definePaywall } from "superwall/config"; @@ -17,7 +17,7 @@ export default definePaywall({ }); ``` -TypeScript is the only validation, so keep the object literal inline — that's what lets the compiler catch typos. If you want to share settings between paywalls, export a plain object and spread it: +TypeScript is the only validation, so keep the object literal inline. That's what lets the compiler catch typos. If you want to share settings between paywalls, export a plain object and spread it: ```ts // superwall/components/shared-config.ts @@ -32,24 +32,24 @@ export default definePaywall({ ...shared, name: "Pro", products: { … } }); | Key | Type | Default | What it does | | --- | --- | --- | --- | | `name` | `string` | directory name, title-cased | The label shown in the dashboard. The directory stays the identifier. | -| `products` | `Record` | — | Product slots by reference — see [below](#products). | -| `transition` | `"push" \| "slide" \| "fade" \| "shift" \| "none"` or custom | `"push"` | Default page transition — see [Transitions](/framework/transitions). | -| `queryState` | `boolean` | on when `checkout` is set | Keep the route stack and every `useQueryState` value in the page URL, so the flow resumes from any link. Never applies on a native host — see [Pages & navigation](/framework/navigation). | -| `checkout` | mode or `{ mode, prefetch? }` | — | Sell on the web. Omit for native-only — see [Web checkout](/framework/web-checkout). | -| `background` | `string` or `{ light, dark? }` | — | Background color painted behind the paywall while it loads and as the page background — see [below](#background). | -| `introductoryOfferEligibility` | `"automatic" \| "alwaysEligible" \| "alwaysIneligible"` | `"automatic"` | Trial eligibility — `automatic` lets the store decide. | +| `products` | `Record` | — | Product slots by reference, see [below](#products). | +| `transition` | `"push" \| "slide" \| "fade" \| "shift" \| "none"` or custom | `"push"` | Default page transition, see [Transitions](/framework/transitions). | +| `queryState` | `boolean` | on when `checkout` is set | Keep the route stack and every `useQueryState` value in the page URL, so the flow resumes from any link. Never applies on a native host, see [Pages & navigation](/framework/navigation). | +| `checkout` | mode or `{ mode, prefetch? }` | — | Sell on the web. Omit for native-only, see [Web checkout](/framework/web-checkout). | +| `background` | `string` or `{ light, dark? }` | — | Background color painted behind the paywall while it loads and as the page background, see [below](#background). | +| `introductoryOfferEligibility` | `"automatic" \| "alwaysEligible" \| "alwaysIneligible"` | `"automatic"` | Trial eligibility. `automatic` lets the store decide. | | `dismissOnPurchase` | `boolean` | — | Auto-dismiss the paywall after a completed purchase. | | `purchaseTimeoutMs` | `number` | — | Resolve a purchase as failed after this long with no result. | -| `notifications` | `{ trialReminder }` | — | Trial-reminder notification — see [below](#trial-reminder-notifications). | -| `localization` | `{ defaultLocale, messages? }` | `"en"` | Fallback locale; file-based catalogs need no config — see [Localization](/framework/localization). | +| `notifications` | `{ trialReminder }` | — | Trial-reminder notification, see [below](#trial-reminder-notifications). | +| `localization` | `{ defaultLocale, messages? }` | `"en"` | Fallback locale; file-based catalogs need no config, see [Localization](/framework/localization). | | `scrollEnabled` | `boolean` | `true` | Whether the paywall scrolls. | -Presentation style, feature gating, and on-device caching are **dashboard settings**, not config keys — they live on the paywall in Superwall, not in `config.ts`. +`presentation`, `featureGating`, and `onDeviceCacheEnabled` are accepted by the type but currently inert. Presentation style, feature gating, and on-device caching are read from the paywall's **dashboard settings**, not from `config.ts`. Setting them here type-checks, builds, and pushes without a warning, and has no effect. -There is deliberately **no identifier field**. The directory path is the paywall's identity, and the dashboard binding lives in `superwall.lock` — never in this file. See [Project structure](/framework/project-structure). +There is deliberately **no identifier field**. The directory path is the paywall's identity, and the dashboard binding lives in `superwall.lock`, never in this file. See [Project structure](/framework/project-structure). ## Background @@ -64,7 +64,7 @@ background: { light: "#ffffff", dark: "#0d0f12" } Colors are 6- or 8-digit hex (`#RRGGBB` or `#RRGGBBAA`). Shorthand like `#fff`, named colors, and `rgb()` are rejected at push. -It does two things from one value: native SDKs paint it behind the webview and derive the loading spinner from it, and the web document uses it as the page background — so the color a shopper sees while the paywall loads matches the color it settles on. It takes effect on the next `superwall publish`; when absent, paywalls fall back to the platform default. +It does two things from one value: native SDKs paint it behind the webview and derive the loading spinner from it, and the web document uses it as the page background, so the color a shopper sees while the paywall loads matches the color it settles on. It takes effect on the next `superwall publish`; when absent, paywalls fall back to the platform default. ## Products @@ -77,13 +77,13 @@ products: { } ``` -Your components read them by reference — `getProduct("annual")`, `purchase("annual")` — and the references are typed, so a typo is a compile error. Web/Stripe products put the Stripe price inside the identifier using the `{test|live}:price_…:{offer}` format. +Your components read them by reference (`getProduct("annual")`, `purchase("annual")`), and the references are typed, so a typo is a compile error. Web/Stripe products put the Stripe price inside the identifier using the `{test|live}:price_…:{offer}` format. -Product **data** — price, period, trial — never appears in this file. It's store-owned and arrives at runtime; a reference the dashboard has no product for renders undefined variables and blocks publishing. The full story, including how to read product variables safely, is in [Products](/framework/products). +Product **data** (price, period, trial) never appears in this file. It's store-owned and arrives at runtime; a reference the dashboard has no product for renders undefined variables and blocks publishing. The full story, including how to read product variables safely, is in [Products](/framework/products). ## Trial reminder notifications -Declare a local notification and the SDK schedules it when a trial actually starts — the paywall doesn't need to be open when it fires: +Declare a local notification and the SDK schedules it when a trial actually starts. The paywall doesn't need to be open when it fires: ```ts notifications: { @@ -95,8 +95,8 @@ notifications: { }, ``` -`title`, `subtitle`, and `body` accept message keys (resolved through `t()`) or literal copy. For full control, pass a function instead — it receives `{ trialEndDate, product, t, locale }` and returns `{ title, body, delayMs }`, or `null` to skip the notification entirely. The trial reminders example shows both forms — see [Examples](/framework/examples). +`title`, `subtitle`, and `body` accept message keys (resolved through `t()`) or literal copy. For full control, pass a function instead. It receives `{ trialEndDate, product, t, locale }` and returns `{ title, body, delayMs }`, or `null` to skip the notification entirely. The trial reminders example shows both forms, see [Examples](/framework/examples). ## Experimenting without rebuilds -Variables are never declared in this file: everything the paywall reads — `useVariables()`, product variables, trial eligibility — is supplied by your app and the store at runtime, and the studio can override all of it live while you preview. Write your paywall to read variables defensively and every one of them becomes experimentable from the dashboard, no rebuild required. See [Variables & personalization](/framework/variables). +Variables are never declared in this file: everything the paywall reads (`useVariables()`, product variables, trial eligibility) is supplied by your app and the store at runtime, and the studio can override all of it live while you preview. Write your paywall to read variables defensively and every one of them becomes experimentable from the dashboard, no rebuild required. See [Variables & personalization](/framework/variables). diff --git a/content/docs/framework/examples.mdx b/content/docs/framework/examples.mdx index 10e94ead..26d62ff0 100644 --- a/content/docs/framework/examples.mdx +++ b/content/docs/framework/examples.mdx @@ -1,9 +1,9 @@ --- title: "Examples" -description: "Complete, standalone example projects — each teaching exactly one idea, from a minimal paywall to trials, funnels, and Rive animations." +description: "Complete, standalone example projects, each teaching exactly one idea, from a minimal paywall to trials, funnels, and Rive animations." --- -Every example is a complete, standalone framework project teaching exactly **one idea**. Styling is deliberately plain so the mechanism is the thing you read. When what you're building matches one, read it before writing code — each `README.md` explains the idea, and every example runs as-is. +Every example is a complete, standalone framework project teaching exactly **one idea**. Styling is deliberately plain so the mechanism is the thing you read. When what you're building matches one, read it before writing code. Each `README.md` explains the idea, and every example runs as-is. ## Use an example @@ -12,14 +12,14 @@ superwall create --example multi-page # scaffold it as a new project superwall dev # open it in the studio ``` -All examples are public at [github.com/superwall/superwall/tree/main/examples](https://github.com/superwall/superwall/tree/main/examples) — browse them there, or copy a directory anywhere for a working `superwall dev`. +All examples are public at [github.com/superwall/superwall/tree/main/examples](https://github.com/superwall/superwall/tree/main/examples), browse them there, or copy a directory anywhere for a working `superwall dev`. ## Fundamentals | Example | The one idea | Reach for it when | | --- | --- | --- | -| [`minimal`](https://github.com/superwall/superwall/tree/main/examples/minimal) | One page, one product, purchase — a paywall is a React component, not a template | Starting anything | -| [`product-selection`](https://github.com/superwall/superwall/tree/main/examples/product-selection) | Selection state is ordinary React — the framework has no "selected plan" concept | Multiple plans, price rows | +| [`minimal`](https://github.com/superwall/superwall/tree/main/examples/minimal) | One page, one product, purchase. A paywall is a React component, not a template | Starting anything | +| [`product-selection`](https://github.com/superwall/superwall/tree/main/examples/product-selection) | Selection state is ordinary React. The framework has no "selected plan" concept | Multiple plans, price rows | `minimal` shows the canonical price guard (render the price only when it exists, with bare copy as the fallback). `product-selection` adds a typed plan union, `haptics.selection()` on choice vs `haptics.light()` on the CTA, a real radiogroup with `aria-checked`, store-formatted `price` and `monthlyPrice` side by side, and a designed unpriced state. @@ -28,39 +28,39 @@ All examples are public at [github.com/superwall/superwall/tree/main/examples](h | Example | The one idea | | --- | --- | | [`multi-page`](https://github.com/superwall/superwall/tree/main/examples/multi-page) | `router.push`/`back`, the page stack, and chrome in `layout.tsx` that reads router state | -| [`transitions`](https://github.com/superwall/superwall/tree/main/examples/transitions) | All five built-ins plus a custom `zoom` — proof a transition is just CSS on two attributes | +| [`transitions`](https://github.com/superwall/superwall/tree/main/examples/transitions) | All five built-ins plus a custom `zoom`, proof a transition is just CSS on two attributes | | [`onboarding-quiz`](https://github.com/superwall/superwall/tree/main/examples/onboarding-quiz) | Answers decide where you land; the router carries no state (a plain module outside React does) | -`onboarding-quiz`'s terminal page defends every read — a replayed page never crashes on a missing answer — and hardcodes step labels per page, because a branching flow's depth is not its step number. +`onboarding-quiz`'s terminal page defends every read, a replayed page never crashes on a missing answer, and hardcodes step labels per page, because a branching flow's depth is not its step number. ## Purchases | Example | The one idea | | --- | --- | | [`purchase-states`](https://github.com/superwall/superwall/tree/main/examples/purchase-states) | The two channels: what your `purchase()` call resolves vs what the SDK reports on its own | -| [`trial-eligibility`](https://github.com/superwall/superwall/tree/main/examples/trial-eligibility) | Two paywalls in one, chosen by the store — every string forks and both states read as intentional | -| [`abandonment-offer`](https://github.com/superwall/superwall/tree/main/examples/abandonment-offer) | `purchase()` resolving `abandoned` is a signal only this paywall can act on — a last-chance offer on a custom `sheet` transition | +| [`trial-eligibility`](https://github.com/superwall/superwall/tree/main/examples/trial-eligibility) | Two paywalls in one, chosen by the store, every string forks and both states read as intentional | +| [`abandonment-offer`](https://github.com/superwall/superwall/tree/main/examples/abandonment-offer) | `purchase()` resolving `abandoned` is a signal only this paywall can act on, a last-chance offer on a custom `sheet` transition | | [`trial-reminders`](https://github.com/superwall/superwall/tree/main/examples/trial-reminders) | A local notification declared in config, scheduled by the SDK when the trial starts | -| [`web-funnel`](https://github.com/superwall/superwall/tree/main/examples/web-funnel) | Selling on the web: steps as pages on the `shift` transition, answers kept in the URL with `useQueryState`, then `checkout: "sheet"` — `purchase()` unchanged | +| [`web-funnel`](https://github.com/superwall/superwall/tree/main/examples/web-funnel) | Selling on the web: steps as pages on the `shift` transition, answers kept in the URL with `useQueryState`, then `checkout: "sheet"`, `purchase()` unchanged | -`purchase-states` is the only example showing the full haptic vocabulary (`success()` / `error()` keyed to outcomes). `abandonment-offer` holds the deepest CSS lesson of the set: the scrim behind its sheet reuses the framework's timing variables, so one number drives both the page dim and the backdrop — see [Transitions](/framework/transitions). +`purchase-states` is the only example showing the full haptic vocabulary (`success()` / `error()` keyed to outcomes). `abandonment-offer` holds the deepest CSS lesson of the set: the scrim behind its sheet reuses the framework's timing variables, so one number drives both the page dim and the backdrop, see [Transitions](/framework/transitions). ## The host | Example | The one idea | | --- | --- | -| [`personalization`](https://github.com/superwall/superwall/tree/main/examples/personalization) | `useVariables()` — device, user, placement params, and guarding every read | +| [`personalization`](https://github.com/superwall/superwall/tree/main/examples/personalization) | `useVariables()`, device, user, placement params, and guarding every read | | [`permissions`](https://github.com/superwall/superwall/tree/main/examples/permissions) | `requestPermission` (asks the OS) vs `requestCallback` (asks *your app*); a denial is an outcome, not an error | ## Look and feel | Example | The one idea | | --- | --- | -| [`custom-fonts`](https://github.com/superwall/superwall/tree/main/examples/custom-fonts) | A typeface from a file in your project — relative-path `@font-face`, subset to latin | -| [`with-tailwind`](https://github.com/superwall/superwall/tree/main/examples/with-tailwind) | Tailwind v4 with zero framework config — and the `dark:` variant redefined onto the SDK's class | +| [`custom-fonts`](https://github.com/superwall/superwall/tree/main/examples/custom-fonts) | A typeface from a file in your project, relative-path `@font-face`, subset to latin | +| [`with-tailwind`](https://github.com/superwall/superwall/tree/main/examples/with-tailwind) | Tailwind v4 with zero framework config, and the `dark:` variant redefined onto the SDK's class | | [`with-motion`](https://github.com/superwall/superwall/tree/main/examples/with-motion) | In-page animation gated on presentation, plus a price count-up gated on the value existing | -| [`with-rive`](https://github.com/superwall/superwall/tree/main/examples/with-rive) | Interactive vector animation — `.riv` as a hosted asset, the WASM engine bundled | -| [`orientation`](https://github.com/superwall/superwall/tree/main/examples/orientation) | `useDevice().orientation` — landscape is a two-column reflow, not a shrunken portrait | +| [`with-rive`](https://github.com/superwall/superwall/tree/main/examples/with-rive) | Interactive vector animation, `.riv` as a hosted asset, the WASM engine bundled | +| [`orientation`](https://github.com/superwall/superwall/tree/main/examples/orientation) | `useDevice().orientation`, landscape is a two-column reflow, not a shrunken portrait | ## Localization diff --git a/content/docs/framework/hooks.mdx b/content/docs/framework/hooks.mdx index 7a06490c..b7dc3553 100644 --- a/content/docs/framework/hooks.mdx +++ b/content/docs/framework/hooks.mdx @@ -1,9 +1,9 @@ --- title: "Hooks Reference" -description: "Every hook the framework provides — signatures, what each returns, and the semantics that matter." +description: "Every hook the framework provides, signatures, what each returns, and the semantics that matter." --- -Everything a paywall reads or triggers comes through hooks. One concern each — there is deliberately no kitchen-sink hook. +Everything a paywall reads or triggers comes through hooks. One concern each. There is deliberately no kitchen-sink hook. ```tsx import { @@ -23,7 +23,7 @@ const { products, getProduct } = useProducts(); const annual = getProduct("annual"); // typed reference — typos are compile errors ``` -Products, keyed by the reference declared in `config.ts`, each carrying store-owned `variables` (`price`, `period`, `trialPeriodDays`, …). A declared reference always exists, but its variables may not have arrived — guard every read and design the empty state. The full variable list and reading rules are in [Products](/framework/products). +Products, keyed by the reference declared in `config.ts`, each carrying store-owned `variables` (`price`, `period`, `trialPeriodDays`, …). A declared reference always exists, but its variables may not have arrived, guard every read and design the empty state. The full variable list and reading rules are in [Products](/framework/products). ## `usePurchase()` @@ -34,7 +34,7 @@ const result = await purchase("annual"); // { status: "completed" | "abandoned" | "failed" } — never throws for flow outcomes ``` -The whole purchase flow — outcomes, the no-loading-state rule, web checkout, and `prefetch` — is in [Purchases](/framework/purchases). +The whole purchase flow (outcomes, the no-loading-state rule, web checkout, and `prefetch`) is in [Purchases](/framework/purchases). ## `useActions()` @@ -42,7 +42,7 @@ The whole purchase flow — outcomes, the no-loading-state rule, web checkout, a const { close, restore, openUrl, requestPermission, requestCallback } = useActions(); ``` -Also on the object: `openExternalUrl`, `openDeepLink`, `customPlacement`, `requestStoreReview`. Everything a paywall asks its host to do — [Actions](/framework/actions) has the full table, the permission types, and the callback pattern. +Also on the object: `openExternalUrl`, `openDeepLink`, `customPlacement`, `requestStoreReview`. Everything a paywall asks its host to do. [Actions](/framework/actions) has the full table, the permission types, and the callback pattern. ## `useHaptics()` @@ -53,7 +53,7 @@ haptics.selection(); // changing a choice haptics.success(); // purchase landed ``` -Also available: `medium`, `heavy`, `warning`, `error`. Fire one on every meaningful tap — iOS produces no feedback of its own inside a paywall. No-ops where haptics are unavailable, so call them unconditionally. +Also available: `medium`, `heavy`, `warning`, `error`. Fire one on every meaningful tap, iOS produces no feedback of its own inside a paywall. No-ops where haptics are unavailable, so call them unconditionally. ## `useTranslation()` @@ -62,7 +62,7 @@ const { t, locale, setLocale, locales } = useTranslation(); t("paywall.cta", { price }); ``` -Localized copy from `messages/.ts` catalogs — the catalog system, fallback rules, and interpolation are in [Localization](/framework/localization). +Localized copy from `messages/.ts` catalogs, the catalog system, fallback rules, and interpolation are in [Localization](/framework/localization). ## `useTrialEligibility()` @@ -70,7 +70,7 @@ Localized copy from `messages/.ts` catalogs — the catalog system, fall const { eligible } = useTrialEligibility(); // boolean | undefined — the store decides ``` -`undefined` until the SDK reports, so gate trial-only UI on `eligible === true`. Splits the paywall into eligible and ineligible versions — both must read as intentional. See [Free trials](/framework/trials). +`undefined` until the SDK reports, so gate trial-only UI on `eligible === true`. Splits the paywall into eligible and ineligible versions, both must read as intentional. See [Free trials](/framework/trials). ## `useVariables()` @@ -78,7 +78,7 @@ const { eligible } = useTrialEligibility(); // boolean | undefined — the sto const { device, user, params } = useVariables(); ``` -Everything the app and SDK told this paywall about the presentation: the SDK-filled `device` record, `user` attributes your app set, and the placement's `params`. All three are host-filled — guard every read. The records, fields, and guarding doctrine are in [Variables & personalization](/framework/variables). +Everything the app and SDK told this paywall about the presentation: the SDK-filled `device` record, `user` attributes your app set, and the placement's `params`. All three are host-filled, so guard every read. The records, fields, and guarding doctrine are in [Variables & personalization](/framework/variables). ## `useUser()` @@ -94,7 +94,7 @@ Shorthand for `useVariables().user` when the device and params records aren't ne const { orientation, platform, deviceModel } = useDevice(); ``` -The same device record as `useVariables().device`, plus `orientation` (`"portrait" | "landscape"`) — measured in the page, so it updates the moment the device turns. See [Variables & personalization](/framework/variables). +The same device record as `useVariables().device`, plus `orientation` (`"portrait" | "landscape"`), measured in the page, so it updates the moment the device turns. See [Variables & personalization](/framework/variables). ## `useColorScheme()` @@ -102,7 +102,7 @@ The same device record as `useVariables().device`, plus `orientation` (`"portrai const scheme = useColorScheme(); // "light" | "dark" ``` -Rarely needed: the framework already keeps a `dark`/`light` class on `` from what the device reports, so style with plain CSS (`:root.dark { … }`). Reach for the hook only when you need the scheme in JavaScript. Never use `@media (prefers-color-scheme: dark)` as the mechanism — see [Styling](/framework/styling). +Rarely needed: the framework already keeps a `dark`/`light` class on `` from what the device reports, so style with plain CSS (`:root.dark { … }`). Reach for the hook only when you need the scheme in JavaScript. Never use `@media (prefers-color-scheme: dark)` as the mechanism, see [Styling](/framework/styling). ## `useSuperwallEvent(name, handler)` @@ -110,7 +110,7 @@ Rarely needed: the framework already keeps a `dark`/`light` class on `` fr useSuperwallEvent("transaction_complete", () => haptics.success()); ``` -Typed SDK events, subscribed for the component's lifetime; an inline arrow handler is fine. The event list and when each fires: [Lifecycle & events](/framework/lifecycle). For anything a dedicated hook covers (products, trial, variables), use the hook — it cannot miss data that arrived before your component subscribed. +Typed SDK events, subscribed for the component's lifetime; an inline arrow handler is fine. The event list and when each fires: [Lifecycle & events](/framework/lifecycle). For anything a dedicated hook covers (products, trial, variables), use the hook. It cannot miss data that arrived before your component subscribed. ## `useSuperwallSnapshot()` @@ -119,7 +119,7 @@ const snapshot = useSuperwallSnapshot(); const opened = snapshot.paywall !== undefined; ``` -The whole runtime state as one subscribed object. Its most common use is gating entry animations on presentation — paywalls are preloaded hidden, and `snapshot.paywall` flips when the paywall is actually shown ([Lifecycle & events](/framework/lifecycle)). It also carries `experiment` (the A/B assignment), `locale`, and the current purchase and transaction state. +The whole runtime state as one subscribed object. Its most common use is gating entry animations on presentation, paywalls are preloaded hidden, and `snapshot.paywall` flips when the paywall is actually shown ([Lifecycle & events](/framework/lifecycle)). It also carries `experiment` (the A/B assignment), `locale`, and the current purchase and transaction state. ## `useSuperwallSession()` @@ -128,7 +128,7 @@ const session = useSuperwallSession(); session.setUserAttributes({ onboardingCompleted: "true" }); ``` -The full session for advanced work — the few methods no hook surfaces (`setUserAttributes`, raw protocol messaging) and use outside React components. If you're reaching for it for products, purchases, actions, or events, use the dedicated hook instead. +The full session for advanced work: the few methods no hook surfaces (`setUserAttributes`, raw protocol messaging) and use outside React components. If you're reaching for it for products, purchases, actions, or events, use the dedicated hook instead. ## `useRouter()` @@ -147,7 +147,7 @@ router.name; // current page router.depth; // pages underneath (index = 0) ``` -The stack router for multi-page flows — expo-router's API, method for method. Page names autocomplete and reject typos via the generated `superwall.d.ts`. [Pages & navigation](/framework/navigation) covers the stack model, state between pages, and shared chrome. +The stack router for multi-page flows, expo-router's API, method for method. Page names autocomplete and reject typos via the generated `superwall.d.ts`. [Pages & navigation](/framework/navigation) covers the stack model, state between pages, and shared chrome. ## `useIsFocused()` @@ -157,7 +157,7 @@ import { useIsFocused } from "superwall/navigation"; const focused = useIsFocused(); ``` -Whether this page is on top of the stack. Pages you navigate away from stay alive — a covered page can't be clicked or focused, and `useIsFocused()` tells it so, so it can pause video or timers. See [Pages & navigation](/framework/navigation). +Whether this page is on top of the stack. Pages you navigate away from stay alive. A covered page can't be clicked or focused, and `useIsFocused()` tells it so, so it can pause video or timers. See [Pages & navigation](/framework/navigation). ## `useQueryState(key, parser?)` @@ -170,7 +170,7 @@ setGoal("focus"); // ?goal=focus setGoal(null); // key removed ``` -`useState` whose value lives in the page URL, so a flow resumes from any link — after a reload, in the OS browser after an in-app one, or back from hosted checkout. On a surface with web checkout the URL is kept automatically, and **every answer in a web funnel goes through this hook** — single choice, multi choice, inputs, the selected plan — never `useState`; see [Web Funnels](/framework/web-funnels). On a native host the same hook is plain state shared across pages. The API is nuqs's: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, plus `.withDefault()` and `.withOptions({ history, clearOnDefault })`. See [Pages & navigation](/framework/navigation). +`useState` whose value lives in the page URL, so a flow resumes from any link: after a reload, in the OS browser after an in-app one, or back from hosted checkout. On a surface with web checkout the URL is kept automatically, and **every answer in a web funnel goes through this hook** (single choice, multi choice, inputs, the selected plan), never `useState`; see [Web Funnels](/framework/web-funnels). On a native host the same hook is plain state shared across pages. The API is nuqs's: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, plus `.withDefault()` and `.withOptions({ history, clearOnDefault })`. See [Pages & navigation](/framework/navigation). ## `ProductReference` @@ -180,4 +180,4 @@ import { type ProductReference } from "superwall/hooks"; const [selected, setSelected] = React.useState("annual"); ``` -The union of product references declared in your `config.ts` — the type behind `getProduct`, `purchase`, and `prefetch`. Use it for selection state so an invalid reference is a compile error. +The union of product references declared in your `config.ts`, the type behind `getProduct`, `purchase`, and `prefetch`. Use it for selection state so an invalid reference is a compile error. diff --git a/content/docs/framework/index.mdx b/content/docs/framework/index.mdx index 923f4d06..cf8af891 100644 --- a/content/docs/framework/index.mdx +++ b/content/docs/framework/index.mdx @@ -1,43 +1,66 @@ --- title: "Superwall Framework" -description: "Build paywalls, onboarding funnels, and web checkout flows as React mini-apps — in your repo, with your tools, shipped without an app update." +description: "Build paywalls, onboarding funnels, and web checkout flows as React mini-apps, in your repo, with your tools, shipped without an app update." --- -The Superwall Framework lets you build paywalls, onboarding funnels, and web checkout flows as code. Each one is a small React app in a `superwall/` directory inside your repo: a `config.ts` that declares its name and products, an `app/` directory of pages, and whatever components, styles, and assets it needs. Superwall provides everything else — products, purchases, localization, trial handling, and the bridge to the native SDKs — so you never touch native code to change what your users see. +Superwall paywalls are normally built in the visual editor. The framework is the other way to build them: as React code, in your own repo, reviewed and versioned like the rest of your app. + +Everything after the build is unchanged. Paywalls are still delivered by the native SDKs, still shown through placements and campaigns, still experimented on from the dashboard, and still updated without an app release. The framework changes how a paywall is **authored**, not how it reaches your users. + +## What you write + +A paywall is a small React app in a `superwall/` directory: ```ts superwall/paywalls/pro/ ├── config.ts definePaywall({ name, products: { annual: "pro_5999_year" } }) -├── app/ pages — index.tsx, plans.tsx, layout.tsx +├── app/ pages: index.tsx, plans.tsx, layout.tsx ├── components/ everything that is not a page └── messages/en.ts localized strings, discovered by filename ``` -You preview locally with `superwall dev`, which opens a studio with device frames, light/dark toggles, locale switching, and simulated purchases. When you're happy, `superwall push` seals an immutable version, and `superwall promote` points production at it — your users get the new paywall on the next open, no app review required. +`config.ts` declares the paywall's name and which products it sells. `app/` holds the pages. Everything else is ordinary React: your components, your CSS, your dependencies. Four hooks connect it to Superwall, with [`useProducts()`](/framework/hooks#useproducts) for live store prices, [`usePurchase()`](/framework/hooks#usepurchase) to make the sale, [`useActions()`](/framework/hooks#useactions) to close or restore, and [`useHaptics()`](/framework/hooks#usehaptics) for the tap. + +You preview locally with `superwall dev`, which opens a studio with device frames, light and dark toggles, locale switching, and simulated purchases. `superwall push` then seals an immutable version and `superwall promote` points production at it. Users get the new paywall on next open, with no app review. + +## Why write a paywall in code + +If you are comfortable in React you could build the UI yourself in an afternoon. What takes longer is everything around it, and that is the part the framework gives you: + +- **Prices you cannot get wrong.** Products are declared as references like `annual: "pro_5999_year"`. Price, period, and trial terms come from the App Store, Google Play, or Stripe at runtime, already localized and formatted. A reference your dashboard has no product for blocks the push outright, so a paywall cannot ship with a stale or hardcoded price. +- **Purchases, restores, and trial eligibility, handled.** The parts that are boring to write, easy to get subtly wrong, and expensive when you do. +- **Shipping without an app release.** A hand-rolled paywall is stuck behind your release train. A pushed one is live on next open, and rolling back is one `promote`. +- **Campaigns and experiments still apply.** A code paywall is A/B tested from the dashboard like any other, so you keep the measurement. +- **Multi-step flows are natural.** An onboarding quiz or web funnel is one paywall whose steps are pages on a stack router. No network between steps and no loading spinners. +- **It lives in your repo.** PRs, code review, CI, your component library, your design tokens. Use Tailwind, Motion, Rive, or plain CSS; the framework has no opinion. + +## Which should I use + +| Build in the framework when | Build in the visual editor when | +| --- | --- | +| Engineers own the paywall | Design or marketing iterate on it directly | +| You want it in git, in PRs, in CI | You want changes without a developer | +| The flow is multi-step, or branches on user answers | The paywall is a single screen | +| You are reusing your app's components or design system | You are starting from a template | +| You need custom animation or interaction | The built-in components cover it | + +Both ship the same way, and an app can use both. The framework trades the no-code loop for code-level control. The framework requires the **headless paywalls** feature to be enabled on your Superwall application. If a push tells you it isn't, contact us to have it turned on. -## Why code-first? - -- **It's just React.** State, components, hooks, CSS — nothing to relearn, and your existing component patterns carry over. Use Tailwind, Motion, Rive, or plain CSS; the framework doesn't care. -- **Version-controlled and reviewable.** Paywalls live in your repo, go through your PR process, and ship from CI if you want them to. -- **Ship without app releases.** Pushed paywalls are delivered remotely by the same SDKs you already use. Promote a new version — or roll back — in seconds. -- **Store data stays store-owned.** Prices, periods, and trials come from the App Store, Google Play, or Stripe at runtime, localized and formatted for each user. You never hardcode a price. -- **One flow, many steps.** Multi-page onboardings and funnels are a single paywall whose steps are pages on a navigation stack — no network between steps, no loading spinners. - ## How it fits together | Piece | What it does | | --- | --- | | `superwall` (npm) | The framework: `definePaywall`, hooks, navigation, the build | | `superwall` (CLI) | `create`, `dev`, `push`, `promote`, `publish` | -| The studio | Local preview at `localhost:6100` — devices, themes, locales, simulated outcomes | +| The studio | Local preview at `localhost:6100`, devices, themes, locales, simulated outcomes | | The dashboard | Where pushed paywalls, versions, and products live; campaigns decide who sees what | | The native SDKs | Present your paywall in-app, deliver product data, run purchases | -Your app keeps presenting paywalls exactly as it does today — through placements and campaigns. The framework changes how paywalls are *built*, not how they're *shown*. +Your app keeps presenting paywalls exactly as it does today, through placements and campaigns. The framework changes how paywalls are *built*, not how they're *shown*. ## Start here @@ -58,8 +81,8 @@ Your app keeps presenting paywalls exactly as it does today — through placemen ## Go deeper -- **[Configuration](/framework/config)** — everything `definePaywall` accepts, from presentation style to trial reminders. -- **[Web checkout](/framework/web-checkout)** — sell the same paywall on the web with one config key. -- **[Variables & personalization](/framework/variables)** — react to user attributes, device state, and placement parameters. -- **[Localization](/framework/localization)** — one file per locale, picked automatically from the device. -- **[Examples](/framework/examples)** — complete standalone projects, each teaching one idea. +- **[Configuration](/framework/config)**, everything `definePaywall` accepts, from presentation style to trial reminders. +- **[Web checkout](/framework/web-checkout)**, sell the same paywall on the web with one config key. +- **[Variables & personalization](/framework/variables)**, react to user attributes, device state, and placement parameters. +- **[Localization](/framework/localization)**, one file per locale, picked automatically from the device. +- **[Examples](/framework/examples)**, complete standalone projects, each teaching one idea. diff --git a/content/docs/framework/lifecycle.mdx b/content/docs/framework/lifecycle.mdx index 7bd4344b..f2988bb1 100644 --- a/content/docs/framework/lifecycle.mdx +++ b/content/docs/framework/lifecycle.mdx @@ -1,13 +1,13 @@ --- title: "Lifecycle & Events" -description: "What a paywall knows and when — the preload rule that shapes every entry animation, and the SDK events you can react to." +description: "What a paywall knows and when, the preload rule that shapes every entry animation, and the SDK events you can react to." --- -The SDK **preloads paywalls hidden** before showing them. Your components mount long before anyone is looking — so a mount-timed animation (a `useEffect` on mount, Motion's `initial`/`animate` firing on mount, a CSS animation on load) has already finished by the time the paywall appears. This one fact shapes every entry animation you'll write. +The SDK **preloads paywalls hidden** before showing them. Your components mount long before anyone is looking, so a mount-timed animation (a `useEffect` on mount, Motion's `initial`/`animate` firing on mount, a CSS animation on load) has already finished by the time the paywall appears. This one fact shapes every entry animation you'll write. ## Gate entry animations on presentation, never mount -The presentation signal is `useSuperwallSnapshot().paywall` — it flips from `undefined` when the paywall is actually shown: +The presentation signal is `useSuperwallSnapshot().paywall`. It flips from `undefined` when the paywall is actually shown: ```tsx import { useSuperwallSnapshot } from "superwall/hooks"; @@ -20,9 +20,9 @@ const opened = useSuperwallSnapshot().paywall !== undefined; /> ``` -Unlike an event listener added in an effect, the snapshot cannot miss the moment — it reads current state rather than waiting to be told. +Unlike an event listener added in an effect, the snapshot cannot miss the moment. It reads current state rather than waiting to be told. -Value-driven animations gate on **both** conditions. A price count-up starts when `opened && raw !== undefined` — never when the store delivers the price (it would play while hidden), and never on a missing value (it would land on a made-up figure): +Value-driven animations gate on **both** conditions. A price count-up starts when `opened && raw !== undefined`, never when the store delivers the price (it would play while hidden), and never on a missing value (it would land on a made-up figure): ```tsx const rawPrice = Number(annual?.variables.rawPrice); @@ -36,7 +36,7 @@ React.useEffect(() => { }, [opened, raw]); ``` -The with-motion [example](/framework/examples) is the reference for both patterns. The ownership rule that goes with them: animation libraries animate *inside* a page — moving *between* pages is the router's job, so spamming navigation can never fight your component animations. See [Transitions](/framework/transitions). +The with-motion [example](/framework/examples) is the reference for both patterns. The ownership rule that goes with them: animation libraries animate *inside* a page, moving *between* pages is the router's job, so spamming navigation can never fight your component animations. See [Transitions](/framework/transitions). ## Events you can react to @@ -50,18 +50,18 @@ useSuperwallEvent("freeTrial_start", () => { /* trial began */ }); | Event | Fires when | | --- | --- | | `paywall_open` | The paywall is presented (or re-presented). Prefer `snapshot.paywall` for anything render-driving. | -| `transaction_complete` | A purchase **or restore** succeeded — whoever started it. | +| `transaction_complete` | A purchase succeeded, whoever started it. A restore does **not** fire this event; see [Purchases](/framework/purchases#restore). | | `transaction_abandon` | The store sheet was closed. | | `freeTrial_start` | A trial actually began. Also triggers the configured [trial reminder](/framework/trials). | | `experiment` | The experiment assignment arrived (`experimentId`, `variantId`, `campaignId`). | | `back_button_input` | Android hardware back. | -| `game_controller_input` | Controller input — needs `gameControllerEnabled: true` in [config](/framework/config). | -| `message` | Every incoming SDK message — the debugging firehose. | +| `game_controller_input` | Controller input, needs `gameControllerEnabled: true` in [config](/framework/config). | +| `message` | Every incoming SDK message, the debugging firehose. | Subscriptions last the component's lifetime; an inline arrow handler is fine. -For products, variables, and trial eligibility, use the dedicated hooks instead of events — they read current state and cannot miss data that arrived before your component subscribed. Data arrives progressively after open (paywall id → products → variables → trial eligibility → experiment), which is one more reason every read is guarded. +For products, variables, and trial eligibility, use the dedicated hooks instead of events. They read current state and cannot miss data that arrived before your component subscribed. Data arrives progressively after open (paywall id → products → variables → trial eligibility → experiment), which is one more reason every read is guarded. ## Dark mode @@ -73,12 +73,12 @@ The device decides; the framework maintains a `dark`/`light` class on ``. :root.dark { --bg: #1c1b19; --fg: #fdfef6; } ``` -Don't use `@media (prefers-color-scheme: dark)` as the mechanism — it cannot see what the device reports and ignores the studio's theme toggle. The class is the mechanism. [Styling](/framework/styling) has the full treatment, including Tailwind. +Don't use `@media (prefers-color-scheme: dark)` as the mechanism, it cannot see what the device reports and ignores the studio's theme toggle. The class is the mechanism. [Styling](/framework/styling) has the full treatment, including Tailwind. ## Dev vs device -The same paywall runs against a simulated host in `superwall dev` and the real SDK on device — purchases are simulated in one and real in the other, product variables are injected by the studio in one and delivered by the SDK on the other, and numeric **product** variables arrive as **strings** on device (device numerics like `daysSinceInstall` stay numbers). The full comparison table is in [The studio](/framework/studio). +The same paywall runs against a simulated host in `superwall dev` and the real SDK on device, purchases are simulated in one and real in the other, product variables are injected by the studio in one and delivered by the SDK on the other, and numeric **product** variables arrive as **strings** on device (device numerics like `daysSinceInstall` stay numbers). The full comparison table is in [The studio](/framework/studio). ## The platform stylesheet -Published paywalls receive a small Superwall-owned stylesheet at serve time — platform-wide behavior like scroll control. Previews apply the same one, so local and published render identically. Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer. +Published paywalls receive a small Superwall-owned stylesheet at serve time, platform-wide behavior like scroll control. Previews apply the same one, so local and published render identically. Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer. diff --git a/content/docs/framework/localization.mdx b/content/docs/framework/localization.mdx index 1577b17f..3a4c721f 100644 --- a/content/docs/framework/localization.mdx +++ b/content/docs/framework/localization.mdx @@ -1,9 +1,9 @@ --- title: "Localization" -description: "Ship a paywall in multiple languages by adding one file per locale — no registration, no wiring." +description: "Ship a paywall in multiple languages by adding one file per locale, no registration, no wiring." --- -Ship a paywall in multiple languages by adding one file per locale. The filename is the locale, and the device picks which one renders — no registration, no wiring. +Ship a paywall in multiple languages by adding one file per locale. The filename is the locale, and the device picks which one renders, no registration, no wiring. ## Add locales @@ -33,7 +33,7 @@ export default { } as const; ``` -A paywall's own catalog layers over the shared one — it overrides the keys it names and inherits the rest. A locale can exist in either layer or both. +A paywall's own catalog layers over the shared one, it overrides the keys it names and inherits the rest. A locale can exist in either layer or both. If your fallback language isn't English, set it in `config.ts`: @@ -41,7 +41,7 @@ If your fallback language isn't English, set it in `config.ts`: localization: { defaultLocale: "en" }, ``` -## Use the strings — `useTranslation()` +## Use the strings: `useTranslation()` ```tsx const { t, locale, setLocale, locales } = useTranslation(); @@ -51,27 +51,27 @@ const { t, locale, setLocale, locales } = useTranslation(); ``` -- **`t(key, values?)`** — the translated string for the active locale. Interpolation is `{name}` in the catalog with `t(key, { name: value })` at the call site. -- **`locale`** — the active locale, resolved from the device. Resolution is specific-to-general: `pt-BR` matches a `pt-BR` catalog first, then `pt`, then the default locale. -- **`setLocale(locale)`** — override the device; `setLocale(undefined)` returns to auto-detection. This is for previews and tests — on device, the system setting is the truth. -- **`locales`** — every locale that has a catalog. +- **`t(key, values?)`**, the translated string for the active locale. Interpolation is `{name}` in the catalog with `t(key, { name: value })` at the call site. +- **`locale`**, the active locale, resolved from the device. Resolution is specific-to-general: `pt-BR` matches a `pt-BR` catalog first, then `pt`, then the default locale. +- **`setLocale(locale)`**, override the device; `setLocale(undefined)` returns to auto-detection. This is for previews and tests. On device, the system setting is the truth. +- **`locales`**: every locale that has a catalog. ## How fallbacks behave -- A key missing from the active locale falls back to the default locale **per key** — a partial translation stays usable while it's being finished. -- An unknown key renders as itself, so `t()` never breaks. The flip side: **key typos are invisible at runtime** — nothing throws, the key just shows up on screen. Check your copy in [the studio](/framework/studio) with its locale switcher. -- Guard interpolations on the value existing, with a bare-key fallback — as in the CTA above. Never render "Subscribe · undefined". +- A key missing from the active locale falls back to the default locale **per key**. A partial translation stays usable while it's being finished. +- An unknown key renders as itself, so `t()` never breaks. The flip side: **key typos are invisible at runtime**. Nothing throws, and the key just shows up on screen. Check your copy in [the studio](/framework/studio) with its locale switcher. +- Guard interpolations on the value existing, with a bare-key fallback, as in the CTA above. Never render "Subscribe · undefined". ## The rules -- **Never put a price in a catalog.** Prices are localized by the store — the SDK delivers the right currency and format for the user's region. Interpolate them: `"Subscribe · {price}"`. See [Products](/framework/products). +- **Never put a price in a catalog.** Prices are localized by the store, the SDK delivers the right currency and format for the user's region. Interpolate them: `"Subscribe · {price}"`. See [Products](/framework/products). - **No language picker on device.** The locale is the person's system setting; preview other locales with the studio's locale switcher. -- **Copy expands.** German runs long — size nothing to fit English. +- **Copy expands.** German runs long, size nothing to fit English. - Product `period` and `periodly` variables ("yearly" → "jährlich") localize automatically in 44 locales, independent of your catalogs. -- A single-locale paywall needs none of this — plain strings in JSX are fine until the second locale arrives. +- A single-locale paywall needs none of this, plain strings in JSX are fine until the second locale arrives. -There is no plural engine — no ICU, no `_one`/`_other` suffixes. Write around plurals, or fork on the count yourself. +There is no plural engine, no ICU, no `_one`/`_other` suffixes. Write around plurals, or fork on the count yourself. The localization [example](/framework/examples) shows four locales, both catalog layers, and guarded interpolation. diff --git a/content/docs/framework/navigation.mdx b/content/docs/framework/navigation.mdx index 2e3f6e93..d0082cf9 100644 --- a/content/docs/framework/navigation.mdx +++ b/content/docs/framework/navigation.mdx @@ -1,9 +1,9 @@ --- title: "Pages & Navigation" -description: "Build multi-page paywalls, onboardings, and funnels with file-based pages and a stack router — no network between steps, no loading spinners." +description: "Build multi-page paywalls, onboardings, and funnels with file-based pages and a stack router, no network between steps, no loading spinners." --- -Multi-page paywalls, onboarding quizzes, and funnels are built from file-based pages and a stack router. Moving between pages never touches the network — the whole flow ships together, so there's no page load, no spinner, and no screen that never arrives. +Multi-page paywalls, onboarding quizzes, and funnels are built from file-based pages and a stack router. Moving between pages never touches the network. The whole flow ships together, so there's no page load, no spinner, and no screen that never arrives. ## Add pages @@ -19,10 +19,10 @@ app/ └── setup.tsx "goals/setup" ``` -File names are lowercase-kebab, and each page default-exports a component. Components that aren't pages go in `components/`, not `app/` — a stray file there is a warning in dev and blocks a push. +File names are lowercase-kebab, and each page default-exports a component. Components that aren't pages go in `components/`, not `app/`. A stray file there is a warning in dev and blocks a push. -Only the top-level `layout.tsx` is special. A nested `goals/layout.tsx` would become a page named `goals/layout` — there are no nested layouts. +Only the top-level `layout.tsx` is special. A nested `goals/layout.tsx` would become a page named `goals/layout`. There are no nested layouts. ## Navigate @@ -45,20 +45,20 @@ router.name; // current page router.depth; // pages underneath (index = 0) ``` -If you've used expo-router, this is the same shape — minus `navigate`/`setParams`, plus `name` and `depth`. Page names autocomplete and reject typos, thanks to the generated `superwall.d.ts` — one more reason to [commit it](/framework/project-structure). +If you've used expo-router, this is the same shape, minus `navigate`/`setParams`, plus `name` and `depth`. Page names autocomplete and reject typos, thanks to the generated `superwall.d.ts`, one more reason to [commit it](/framework/project-structure). A few rules make navigation feel right: - **Closing the paywall is `useActions().close()`**, not navigation. The stack is for moving within the flow; closing hands control back to your app. See [Actions](/framework/actions). - **Pages you navigate away from stay alive.** Going back restores a page exactly as it was left, scroll position and state included. A covered page can't be clicked or focused; `useIsFocused()` tells a page it's covered so it can pause video or timers. -- **There is no declared page order.** Any page can push any page — which is exactly what makes branching flows possible. +- **There is no declared page order.** Any page can push any page, which is exactly what makes branching flows possible. - **Page views are tracked for you.** Every navigation reports analytics automatically; there's nothing to instrument. ## Pass state between pages Navigation carries no params, on purpose. Cross-page state has two homes: -**`layout.tsx`** stays mounted for the whole flow — React state or context there is visible to every page: +**`layout.tsx`** stays mounted for the whole flow, React state or context there is visible to every page: ```tsx export default function Layout({ children }: PropsWithChildren) { @@ -66,7 +66,7 @@ export default function Layout({ children }: PropsWithChildren) { } ``` -**A plain module** works even after the collecting page is gone — the quiz pattern, from the onboarding quiz example (see [Examples](/framework/examples)): +**A plain module** works even after the collecting page is gone, the quiz pattern, from the onboarding quiz example (see [Examples](/framework/examples)): ```ts // components/answers.ts @@ -81,9 +81,9 @@ const choose = (value: Goal) => { }; ``` -Guard every read on the destination — `answers.goal ? PLAN[answers.goal] : undefined` — so a revisited page never crashes on a missing answer. +Guard every read on the destination, `answers.goal ? PLAN[answers.goal] : undefined`, so a revisited page never crashes on a missing answer. -**The URL**, for flows on the web — and on a web funnel this is not one option among three, it is the rule. Neither home above survives a reload, and an in-app browser (Instagram, TikTok) hands only the link to Safari when someone taps "open in browser" — its storage stays behind. So a surface with [web checkout](/framework/web-checkout) keeps its state in the page URL: the route stack goes in automatically, and **every answer, selection and input** is kept with `useQueryState`, never `useState`: +**The URL**, for flows on the web, and on a web funnel this is not one option among three. It is the rule. Neither home above survives a reload, and an in-app browser (Instagram, TikTok) hands only the link to Safari when someone taps "open in browser". Its storage stays behind. So a surface with [web checkout](/framework/web-checkout) keeps its state in the page URL: the route stack goes in automatically, and **every answer, selection and input** is kept with `useQueryState`, never `useState`: ```tsx import { parseAsStringEnum, useQueryState } from "superwall/navigation"; @@ -94,13 +94,13 @@ setGoal("focus"); // ?goal=focus — and the plan page reads the same hoo router.push("plan"); ``` -The API is [nuqs](https://nuqs.dev)'s, so the parsers read the same: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, each with `.withDefault()` and `.withOptions({ history, clearOnDefault })`. Any link then resumes the flow on the same step with the same answers — after a reload, in the OS browser, or back from hosted checkout — and the browser's back button is `router.back()`. +The API is [nuqs](https://nuqs.dev)'s, so the parsers read the same: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, each with `.withDefault()` and `.withOptions({ history, clearOnDefault })`. Any link then resumes the flow on the same step with the same answers, after a reload, in the OS browser, or back from hosted checkout, and the browser's back button is `router.back()`. -[Web Funnels](/framework/web-funnels) has the full treatment — multi choice, inputs, branching, the URL budget. Three rules keep it honest: +[Web Funnels](/framework/web-funnels) has the full treatment, multi choice, inputs, branching, the URL budget. Three rules keep it honest: -- **Only what a page asks for is persisted.** The framework never decides what an answer is. Keys starting with `sw_`, plus `platform` and `transport`, are reserved — the hook throws on them. -- **Mind the URL budget.** About 2 kB is safe across every app and share sheet — keep keys short and values enumerable, and keep anything personal out of a URL. -- **The same code runs natively.** In an SDK webview there is no URL bar, so `useQueryState` is plain state shared across pages — the flow reads identically everywhere. `definePaywall({ queryState: true | false })` overrides the checkout default on web builds; a native host forces it off regardless. +- **Only what a page asks for is persisted.** The framework never decides what an answer is. Keys starting with `sw_`, plus `platform` and `transport`, are reserved, the hook throws on them. +- **Mind the URL budget.** About 2 kB is safe across every app and share sheet, keep keys short and values enumerable, and keep anything personal out of a URL. +- **The same code runs natively.** In an SDK webview there is no URL bar, so `useQueryState` is plain state shared across pages. The flow reads identically everywhere. `definePaywall({ queryState: true | false })` overrides the checkout default on web builds; a native host forces it off regardless. ## Shared chrome @@ -116,28 +116,29 @@ const router = useRouter(); ``` -`depth + 1` works as a step counter only in linear flows. In a branching flow, a page's depth isn't its step number — label steps per page instead. +`depth + 1` works as a step counter only in linear flows. In a branching flow, a page's depth isn't its step number. Label steps per page instead. -When the layout wraps chrome around the pages, set two variables in `:root`: +When the layout wraps chrome around the pages, add one more variable in `:root`: ```css :root { - --sw-background: var(--bg); /* pages are opaque; give them your background */ --sw-routes-height: auto; /* let the layout own the height, or its footer is pushed off-screen */ } ``` -Position overlay chrome absolutely *over* the pages rather than as a bar above them — each page paints its own background, so a bar of its own shows as a seam during transitions. +`--sw-routes-height` is the layout-specific one. `--sw-background` is needed on every paywall, layout or not; see [Styling](/framework/styling#background-color). + +Position overlay chrome absolutely *over* the pages rather than as a bar above them. Each page paints its own background, so a bar of its own shows as a seam during transitions. ## A funnel is one paywall, not several -Multi-step flows — onboarding quizzes, web funnels — are **one paywall whose steps are pages**, not a chain of separate paywalls. Every step is a `router.push` in the same flow, so there's no load between steps and nothing to re-fetch. The structure is identical — `config.ts` plus `app/` pages plus `layout.tsx` — and funnels live in `superwall/funnels//` with exactly the same shape. +Multi-step flows (onboarding quizzes, web funnels) are **one paywall whose steps are pages**, not a chain of separate paywalls. Every step is a `router.push` in the same flow, so there's no load between steps and nothing to re-fetch. The structure is identical (`config.ts` plus `app/` pages plus `layout.tsx`), and funnels live in `superwall/funnels//` with exactly the same shape. -The web funnel example is the reference: question steps kept in the URL, a typed plan selector, then `purchase(reference)` at the end — with [web checkout](/framework/web-checkout) taking payment in the same flow. Funnels usually want `transition: "shift"` in `config.ts` — see [Transitions](/framework/transitions). +The web funnel example is the reference: question steps kept in the URL, a typed plan selector, then `purchase(reference)` at the end, with [web checkout](/framework/web-checkout) taking payment in the same flow. Funnels usually want `transition: "shift"` in `config.ts`, see [Transitions](/framework/transitions). ## Where transitions and animation fit -How pages move — the built-in transitions, custom ones, and bottom sheets — is covered in [Transitions](/framework/transitions). Animation *inside* a page (Motion, CSS) is yours; moving *between* pages stays the router's job. Keeping that line means spamming navigation can never fight your component animations. And entry animations gate on presentation, never mount — see [Lifecycle & events](/framework/lifecycle). +How pages move (the built-in transitions, custom ones, and bottom sheets) is covered in [Transitions](/framework/transitions). Animation *inside* a page (Motion, CSS) is yours; moving *between* pages stays the router's job. Keeping that line means spamming navigation can never fight your component animations. And entry animations gate on presentation, never mount. See [Lifecycle & events](/framework/lifecycle). -Assets for upcoming pages preload automatically while the user is on the current page — see [Assets](/framework/assets). +Assets for upcoming pages preload automatically while the user is on the current page, see [Assets](/framework/assets). diff --git a/content/docs/framework/products.mdx b/content/docs/framework/products.mdx index 3bb16b51..85f0ac0e 100644 --- a/content/docs/framework/products.mdx +++ b/content/docs/framework/products.mdx @@ -3,7 +3,7 @@ title: "Products" description: "Declare product slots in config.ts, read live store data through useProducts, and follow the three rules that keep prices honest." --- -Products connect your paywall to the things it sells. You declare them once in `config.ts`, and everything about them — price, period, trial — arrives from the store at runtime, localized and formatted for each user. You never hardcode a price. +Products connect your paywall to the things it sells. You declare them once in `config.ts`, and everything about them (price, period, trial) arrives from the store at runtime, localized and formatted for each user. You never hardcode a price. ## Declare products @@ -30,11 +30,11 @@ products: { }, ``` -Your code only ever speaks in references — `getProduct("annual")`, `purchase("annual")` — so swapping the underlying store product is a one-line config change. +Your code only ever speaks in references (`getProduct("annual")`, `purchase("annual")`), so swapping the underlying store product is a one-line config change. ### Web and Stripe products -Web paywalls sell through Stripe, and the Stripe price lives inside the identifier — no separate mapping. The format is `{environment}:{priceId}:{offer}`, where `{environment}` is exactly `test` or `live`: +Web paywalls sell through Stripe, and the Stripe price lives inside the identifier, no separate mapping. The format is `{environment}:{priceId}:{offer}`, where `{environment}` is exactly `test` or `live`: ```ts products: { @@ -42,11 +42,11 @@ products: { }, ``` -A paywall can declare both kinds side by side — store products for native, Stripe products for the web. See [Web checkout](/framework/web-checkout) for how the same `purchase()` call sells on both. +A paywall can declare both kinds side by side, store products for native, Stripe products for the web. See [Web checkout](/framework/web-checkout) for how the same `purchase()` call sells on both. ### Product data never appears in the file -Price, period, and trial are store-owned and arrive at runtime. `superwall push` refuses to publish a reference the dashboard has no product for — every variable on it would be `undefined` on device. Example identifiers in scaffolds and examples are placeholders to repoint at your own products. +Price, period, and trial are store-owned and arrive at runtime. `superwall push` refuses to publish a reference the dashboard has no product for. Every variable on it would be `undefined` on device. Example identifiers in scaffolds and examples are placeholders to repoint at your own products. ## Read product data @@ -74,7 +74,7 @@ The variables you'll reach for, all optional: | Locale | `locale`, `languageCode` | | State | `identifier`, `isSubscribed` | -`period` and `periodly` arrive pre-localized to the device locale — "yearly" becomes "jährlich" on a German device, with no work on your side. +`period` and `periodly` arrive pre-localized to the device locale, "yearly" becomes "jährlich" on a German device, with no work on your side. ## The three rules @@ -82,17 +82,17 @@ Three habits keep product data honest. ### 1. Guard every read and design the unpriced state -A declared reference always exists, but its variables may not have arrived yet — and in `superwall dev` they're `undefined` until the studio injects your dashboard's products. Degrade the copy; never invent a number: +A declared reference always exists, but its variables may not have arrived yet, and in `superwall dev` they're `undefined` until the studio injects your dashboard's products. Degrade the copy; never invent a number: ```tsx {annual?.variables.price ? `Subscribe · ${annual.variables.price}` : "Subscribe"} ``` -The unpriced state isn't an error state — your paywall will render it, so design it to read as intentional. +The unpriced state isn't an error state. Your paywall will render it, so design it to read as intentional. ### 2. `Number()` before arithmetic -Numeric-looking variables arrive as **strings** on device (`"59.99"`, `"7"`). A `typeof x === "number"` check passes in dev and silently fails on a real phone — treating every product as trial-less: +Numeric-looking variables arrive as **strings** on device (`"59.99"`, `"7"`). A `typeof x === "number"` check passes in dev and silently fails on a real phone, treating every product as trial-less: ```tsx const days = Number(annual?.variables.trialPeriodDays); @@ -101,11 +101,11 @@ const trialDays = Number.isFinite(days) ? days : 0; ### 3. Display formatted, compute raw -Use `price` and `monthlyPrice` for copy — they're formatted by the store for the user's region and currency. Use `rawPrice` when you need to compute or animate. Never derive a displayed price the store already provides: your division will disagree with the store's own math somewhere in the world. +Use `price` and `monthlyPrice` for copy. They're formatted by the store for the user's region and currency. Use `rawPrice` when you need to compute or animate. Never derive a displayed price the store already provides: your division will disagree with the store's own math somewhere in the world. ## Selection state is ordinary React -The framework has no "selected plan" concept — selection is your state, typed against the config: +The framework has no "selected plan" concept, selection is your state, typed against the config: ```tsx import { type ProductReference } from "superwall/hooks"; @@ -117,7 +117,7 @@ The `product-selection` [example](/framework/examples) shows the full pattern: a ## Create the products on the dashboard -A push refuses if `config.ts` names a product the dashboard doesn't have — Stripe identifiers included. Store products can be created straight from the CLI; Stripe products are imported into the dashboard from Stripe instead, and the flags below don't apply to them. +A push refuses if `config.ts` names a product the dashboard doesn't have, Stripe identifiers included. Store products can be created straight from the CLI; Stripe products are imported into the dashboard from Stripe instead, and the flags below don't apply to them. ```bash superwall products create pro_5999_year \ diff --git a/content/docs/framework/project-structure.mdx b/content/docs/framework/project-structure.mdx index 6e41ed28..156b53f4 100644 --- a/content/docs/framework/project-structure.mdx +++ b/content/docs/framework/project-structure.mdx @@ -3,7 +3,7 @@ title: "Project Structure" description: "How a superwall/ directory is laid out, the two files the CLI manages, and the rules that keep a project portable." --- -Everything Superwall-related in your app lives in one `superwall/` directory — or the repo root, if you keep paywalls in a dedicated repo. It's a self-contained npm project: clone it, install, run `superwall dev`, and it works. Your host app needs no npm setup of its own. +Everything Superwall-related in your app lives in one `superwall/` directory, or the repo root, if you keep paywalls in a dedicated repo. It's a self-contained npm project: clone it, install, run `superwall dev`, and it works. Your host app needs no npm setup of its own. ## Layout @@ -36,15 +36,15 @@ import { Button } from "@/components/Button"; A few conventions keep every project buildable, portable, and understandable at a glance: -- **`app/` holds pages and nothing else.** Every `.tsx` file in `app/` is a page — lowercase-kebab filename, default-exported component. `layout.tsx` at the top level is the one reserved name; stylesheets may sit beside pages. Anything else belongs in `components/`. A stray file in `app/` is a warning in dev and blocks a push. +- **`app/` holds pages and nothing else.** Every `.tsx` file in `app/` is a page, lowercase-kebab filename, default-exported component. `layout.tsx` at the top level is the one reserved name; stylesheets may sit beside pages. Anything else belongs in `components/`. A stray file in `app/` is a warning in dev and blocks a push. - **Every paywall starts at `app/index.tsx`** and must have a `config.ts`. -- **The directory name is the identifier.** It's the URL in dev and the dashboard binding on push — lowercase-kebab. The `name` in `config.ts` is only the human-readable label shown in the dashboard. -- **No build tooling.** No vite config, no `index.html`, no entry point — the framework owns the build end to end. +- **The directory name is the identifier.** It's the URL in dev and the dashboard binding on push, lowercase-kebab. The `name` in `config.ts` is only the human-readable label shown in the dashboard. +- **No build tooling.** No vite config, no `index.html`, no entry point. The framework owns the build end to end. - **Never name the package `"superwall"`** in `package.json`. That would shadow the framework import. `superwall create` names it after your app. -Commands work from your app root or from inside `superwall/` alike, and a globally installed `superwall` always defers to the project's own installed version — so everyone on the team builds with the version the project pins. +Commands work from your app root or from inside `superwall/` alike, and a globally installed `superwall` always defers to the project's own installed version, so everyone on the team builds with the version the project pins. -## Two files the CLI manages — commit both +## Two files the CLI manages: commit both ### `superwall.d.ts` @@ -52,20 +52,20 @@ Regenerated on every `dev` and `push`. It's what makes `router.push("plans")` au ### `superwall.lock` -Binds each paywall directory to its paywall on the dashboard, and records which Superwall app the project pushes to. Committing it is what makes every machine — and CI — push to the same paywalls. Nothing about the dashboard ever appears in `config.ts`; the lock file is the only place bindings live. +Binds each paywall directory to its paywall on the dashboard, and records which Superwall app the project pushes to. Committing it is what makes every machine, and CI, push to the same paywalls. Nothing about the dashboard ever appears in `config.ts`; the lock file is the only place bindings live. Renaming a paywall directory is safe: the next `push` notices and asks whether it's a rename (keeping the live paywall attached) or a brand-new paywall. In CI, declare it with `--rename old=new`. See [Push, promote & publish](/framework/push-and-promote). ## Keep imports inside the project -Import from within `superwall/` or from packages listed in its `package.json`. An import that reaches outside — say `../../src/theme` — still builds on your machine, but the pushed source can no longer be rebuilt anywhere else, so the push warns and names each offender, and the version is recorded as non-portable. +Import from within `superwall/` or from packages listed in its `package.json`. An import that reaches outside, say `../../src/theme`, still builds on your machine, but the pushed source can no longer be rebuilt anywhere else, so the push warns and names each offender, and the version is recorded as non-portable. Copy shared code into `superwall/components/` instead. Duplication here is deliberate: it's what keeps the project self-contained. ## `.env` -`superwall/.env` (with your app root's `.env` as a fallback) holds project credentials — `SUPERWALL_API_KEY` for CI pushes. It's gitignored and never leaves your machine: source pushes exclude `.env*`, `node_modules/`, `.superwall/`, `.git/`, and anything `superwall/.gitignore` lists. +`superwall/.env` (with your app root's `.env` as a fallback) holds project credentials, `SUPERWALL_API_KEY` for CI pushes. It's gitignored and never leaves your machine: source pushes exclude `.env*`, `node_modules/`, `.superwall/`, `.git/`, and anything `superwall/.gitignore` lists. ## Funnels -Multi-step flows — onboarding quizzes, web funnels — use exactly the same layout as paywalls and live under `superwall/funnels//`. A funnel is one surface whose steps are pages, not a chain of separate paywalls. See [Pages & navigation](/framework/navigation). +Multi-step flows (onboarding quizzes, web funnels) use exactly the same layout as paywalls and live under `superwall/funnels//`. A funnel is one surface whose steps are pages, not a chain of separate paywalls. See [Pages & navigation](/framework/navigation). diff --git a/content/docs/framework/purchases.mdx b/content/docs/framework/purchases.mdx index 993c9aa5..f69ab1b0 100644 --- a/content/docs/framework/purchases.mdx +++ b/content/docs/framework/purchases.mdx @@ -1,6 +1,6 @@ --- title: "Purchases" -description: "Make the sale with usePurchase — handle completed, abandoned, and failed outcomes, restore purchases, and react to transactions from anywhere." +description: "Make the sale with usePurchase, handle completed, abandoned, and failed outcomes, restore purchases, and react to transactions from anywhere." --- A purchase is one call: pass a product reference, await the result, react to what happened. The SDK owns the store sheet, the payment, and the receipt. @@ -24,13 +24,17 @@ const haptics = useHaptics(); ## The three outcomes -`purchase()` resolves — it never throws for flow outcomes: +`purchase()` resolves, it never throws for flow outcomes: | Status | Meaning | Respond by | | --- | --- | --- | | `completed` | The sale went through | `haptics.success()`; the SDK dismisses the paywall if configured | -| `abandoned` | The user closed the store sheet | Treat as an ordinary outcome — most people who open a sheet close it: show a last-chance offer, or nothing | -| `failed` | No transaction happened. On a store purchase `reason` is `"timeout"` or `"superseded"` (a retry or re-presentation replaced this attempt); web checkout failures carry no `reason` | Usually nothing; `haptics.error()` at most | +| `abandoned` | The user closed the store sheet | Treat as an ordinary outcome, most people who open a sheet close it: show a last-chance offer, or nothing | +| `failed` | No transaction happened. On a store purchase `reason` is `"timeout"` or `"superseded"` (a newer purchase for the same product reference, or a paywall re-open); web checkout failures carry no `reason` | Usually nothing; `haptics.error()` at most | + +**A store transaction that fails outright does not land here yet.** A declined card or store error arrives from the SDK as `transaction_fail`, which the framework does not currently model, so `purchase()` stays pending rather than resolving `failed`. + +Set `purchaseTimeoutMs` in [`config.ts`](/framework/config) on any paywall that gates UI on the awaited result. Without it there is no timer, so a declined card leaves the promise pending and `isPurchasing` stuck `true`. ### Reacting to abandoned @@ -55,7 +59,7 @@ Both default to what [`config.ts`](/framework/config) declares (`dismissOnPurcha ## The two channels -Your `purchase()` call is one channel. The SDK reporting on its own is the other — it reports what happened, whether or not this paywall started it: a purchase completing, a trial beginning, a sheet being abandoned. +Your `purchase()` call is one channel. The SDK reporting on its own is the other, it reports what happened, whether or not this paywall started it: a purchase completing, a trial beginning, a sheet being abandoned. ```tsx // this paywall's own attempt @@ -77,14 +81,15 @@ See [Lifecycle & events](/framework/lifecycle) for the full event list. import { useActions, useHaptics } from "superwall/hooks"; const { restore } = useActions(); +const haptics = useHaptics(); ``` -`restore()` is fire-and-forget — there is no result to await, and no event you can subscribe to today. Success surfaces as a dismissed paywall. Every store paywall should offer restore — App Review expects it. +`restore()` is fire-and-forget. There is no result to await, and no event you can subscribe to today. Success surfaces as a dismissed paywall. Every store paywall should offer restore, App Review expects it. ## Selling beyond the App Store -Trials — who's eligible, what to show each side — have their own page: [Free trials](/framework/trials). And a single config key sells the same paywall on the web through Stripe, with `purchase()` unchanged: [Web checkout](/framework/web-checkout). +Trials, who's eligible, what to show each side, have their own page: [Free trials](/framework/trials). And a single config key sells the same paywall on the web through Stripe, with `purchase()` unchanged: [Web checkout](/framework/web-checkout). diff --git a/content/docs/framework/push-and-promote.mdx b/content/docs/framework/push-and-promote.mdx index 730b80b0..b281a920 100644 --- a/content/docs/framework/push-and-promote.mdx +++ b/content/docs/framework/push-and-promote.mdx @@ -15,7 +15,7 @@ superwall publish -m "Q3 test" # record why The scaffolded project mirrors these as package scripts (`dev`, `push`, `promote`, `ship`). -Pushing requires the **headless paywalls** feature to be enabled on your Superwall application — it's a server-side flag, so if a push says it isn't enabled, the account owner needs to have it turned on. +Pushing requires the **headless paywalls** feature to be enabled on your Superwall application. It's a server-side flag, so if a push says it isn't enabled, the account owner needs to have it turned on. ## `superwall push` @@ -28,12 +28,12 @@ Builds every paywall, versions the changed ones, and leaves production alone. Re | `--rename =` | Declare a directory rename (see below) | | `-m ` | Record why this version exists | -The **first push binds** each paywall — creating it on Superwall if needed — and records the binding in `superwall.lock`. Commit that file: it's what makes every machine and CI push to the same paywalls. After that, push always updates the same paywall; no IDs ever appear in your code. +The **first push binds** each paywall, creating it on Superwall if needed, and records the binding in `superwall.lock`. Commit that file: it's what makes every machine and CI push to the same paywalls. After that, push always updates the same paywall; no IDs ever appear in your code. -A push refuses — before anything is written — when: +A push refuses, before anything is written, when: - **A selected paywall has diagnostics.** Publishing is immutable; fix the named problems first. They're the same warnings `superwall dev` prints. -- **A product in `config.ts` doesn't exist on the dashboard.** Every variable on it would be undefined on device. Create the products first — see [Products](/framework/products) and the [CLI reference](/framework/cli). +- **A product in `config.ts` doesn't exist on the dashboard.** Every variable on it would be undefined on device. Create the products first, see [Products](/framework/products) and the [CLI reference](/framework/cli). - **A directory rename is unresolved** (below). ## Renames @@ -46,7 +46,7 @@ Renaming a paywall directory is detected, never guessed. Interactively, push ask Create a new paywall ``` -Choosing the rename keeps the live paywall attached to the new directory. In CI there's no one to ask, so declare it — anything unresolved stops the push rather than silently creating a duplicate: +Choosing the rename keeps the live paywall attached to the new directory. In CI there's no one to ask, so declare it, anything unresolved stops the push rather than silently creating a duplicate: ```bash superwall push --rename plus-upgrade=pro-upgrade @@ -56,17 +56,17 @@ Deleting a paywall directory never blocks a push: the dashboard paywall keeps se ## Source snapshots -Every push also snapshots your `superwall/` source to Superwall, so the exact code each version was built from is recoverable. The `-m "why"` note is recorded on that source commit — it only lands when the source actually changed. +Every push also snapshots your `superwall/` source to Superwall, so the exact code each version was built from is recoverable. The `-m "why"` note is recorded on that source commit, it only lands when the source actually changed. What never leaves your machine: `.env` files, `node_modules/`, `.superwall/`, `.git/`, and anything `superwall/.gitignore` lists. -If any import reaches outside the project directory, the push warns naming each offender and records the version as non-portable — the pushed source can't be rebuilt elsewhere. Copy shared code into `superwall/components/` instead. See [Project structure](/framework/project-structure). +If any import reaches outside the project directory, the push warns and records the version as non-portable. The pushed source can't be rebuilt elsewhere. The warning names the first five offenders, then counts the rest. Either copy the shared code into `superwall/components/`, or install it from a package registry. See [Project structure](/framework/project-structure). ## `superwall promote` -Points production at a pushed version. Promote never rebuilds — it only moves the live pointer, so it's instant, and rollback is the same move in reverse: +Points production at a pushed version. Promote never rebuilds. It only moves the live pointer, so it's instant, and rollback is the same move in reverse: ```bash superwall promote # latest push, every paywall @@ -75,17 +75,17 @@ superwall promote --id plus-upgrade --version 5 # → Rolled back version 7 → 5 ``` -`--version`/`-v` (with a single `--id`) picks a specific version — pinning forward or rolling back are the same operation. +`--version`/`-v` (with a single `--id`) picks a specific version, pinning forward or rolling back are the same operation. ## `superwall publish` Push + promote in one step. It also warns about other paywalls that are pushed-but-not-live, so nothing ships half-forgotten. -`push` and `publish` both require git — the source snapshot is part of each. +`push` and `publish` both require git. The source snapshot is part of each. ## CI -Interactive machines authenticate once with `superwall login`. In CI, set `SUPERWALL_API_KEY` (an `sk_…` key) in the environment — `superwall/.env` works locally and is gitignored. `dev` needs no login at all. +Interactive machines authenticate once with `superwall login`. In CI, set `SUPERWALL_API_KEY` (an `sk_…` key) in the environment. `superwall/.env` works locally and is gitignored. `dev` needs no login at all. A typical CI ship step: diff --git a/content/docs/framework/quickstart.mdx b/content/docs/framework/quickstart.mdx index 6119a3ea..0eff953e 100644 --- a/content/docs/framework/quickstart.mdx +++ b/content/docs/framework/quickstart.mdx @@ -10,7 +10,7 @@ This guide takes you from nothing to a live paywall: scaffold a project inside y You'll need: - **Node 20.12+** (or Bun) and **git**. -- A **Superwall account** with an application. The application must have the **headless paywalls** feature enabled — a push will tell you if it isn't. +- A **Superwall account** with an application. The application must have the **headless paywalls** feature enabled. A push will tell you if it isn't. - The **Superwall CLI**: @@ -34,9 +34,9 @@ From the root of your app's repo: superwall create ``` -This scaffolds a self-contained `superwall/` directory — its own `package.json`, a starter paywall, and everything wired up — then connects it to your Superwall app and installs dependencies. Your app itself needs no npm setup. +This scaffolds a self-contained `superwall/` directory, its own `package.json`, a starter paywall, and everything wired up, then connects it to your Superwall app and installs dependencies. Your app itself needs no npm setup. -To start from a working pattern instead, scaffold any [example](/framework/examples) — each is a complete project: +To start from a working pattern instead, scaffold any [example](/framework/examples). Each is a complete project: ```bash superwall create --example multi-page @@ -49,9 +49,9 @@ superwall create --example multi-page superwall dev ``` -This opens the studio at `http://localhost:6100`: every paywall as a card with a live preview, and an editor per paywall with a device-frame view at exact logical size. Switch devices, toggle light and dark, rotate, change locales, and simulate purchases — the studio asks *you* to pick each outcome, so you can test every branch of your flow. See [The studio](/framework/studio) for the full tour. +This opens the studio at `http://localhost:6100`: every paywall as a card with a live preview, and an editor per paywall with a device-frame view at exact logical size. Switch devices, toggle light and dark, rotate, change locales, and simulate purchases, the studio asks *you* to pick each outcome, so you can test every branch of your flow. See [The studio](/framework/studio) for the full tour. -Edits hot-reload as you save. Warnings about project problems (a stray file in `app/`, a duplicate route) appear here too — they're the same checks that block a push, so fix them as they come up. +Edits hot-reload as you save. Warnings about project problems (a stray file in `app/`, a duplicate route) appear here too. They're the same checks that block a push, so fix them as they come up. @@ -87,12 +87,12 @@ export default function Paywall() { } ``` -One thing to know from day one: **product data arrives at runtime.** Prices come from the store, and in dev they're `undefined` until the studio injects your dashboard's products — so guard every read rather than assuming a number is there. See [Products](/framework/products). +One thing to know from day one: **product data arrives at runtime.** Prices come from the store, and in dev they're `undefined` until the studio injects your dashboard's products, so guard every read rather than assuming a number is there. See [Products](/framework/products). -`config.ts` declares product **slots** — the key is the name your code uses, the value is the store identifier: +`config.ts` declares product **slots**. The key is the name your code uses; the value is the store identifier: ```ts import { definePaywall } from "superwall/config"; @@ -105,7 +105,7 @@ export default definePaywall({ }); ``` -The identifiers must exist as products on your Superwall dashboard — a push refuses otherwise. Create them in the dashboard, or from the CLI with `superwall products create`. See [Products](/framework/products). +The identifiers must exist as products on your Superwall dashboard. A push refuses otherwise. Create them in the dashboard, or from the CLI with `superwall products create`. See [Products](/framework/products). @@ -115,12 +115,12 @@ superwall push # build + seal an immutable version — production untouche superwall promote # point production at the latest push ``` -Push saves, promote ships — the same split as git push and a deploy. `superwall publish` does both in one step. The first push binds each paywall to your dashboard and records the binding in `superwall.lock`; commit that file so every machine and CI push to the same paywalls. See [Push, promote & publish](/framework/push-and-promote). +Push saves, promote ships: the same split as git push and a deploy. `superwall publish` does both in one step. The first push binds each paywall to your dashboard and records the binding in `superwall.lock`; commit that file so every machine and CI push to the same paywalls. See [Push, promote & publish](/framework/push-and-promote). -Nothing changes on the app side: add the paywall to a campaign in the dashboard, and your existing `register` / placement calls present it. If you're new to Superwall, follow your platform's quickstart — [iOS](/ios), [Android](/android), [Expo](/expo), or [Flutter](/flutter) — to get the SDK configured and a placement registered. +Nothing changes on the app side: add the paywall to a campaign in the dashboard, and your existing `register` / placement calls present it. If you're new to Superwall, follow your platform's quickstart, [iOS](/ios), [Android](/android), [Expo](/expo), or [Flutter](/flutter), to get the SDK configured and a placement registered. @@ -135,7 +135,7 @@ Nothing changes on the app side: add the paywall to a campaign in the dashboard, Turn one page into a multi-step flow. - Handle completed, abandoned, and failed — and why the buy button never shows a spinner. + Handle completed, abandoned, and failed, and why the buy button never shows a spinner. Complete projects for product selection, onboarding quizzes, trials, and more. diff --git a/content/docs/framework/studio.mdx b/content/docs/framework/studio.mdx index 4fcafe41..11328c4a 100644 --- a/content/docs/framework/studio.mdx +++ b/content/docs/framework/studio.mdx @@ -1,6 +1,6 @@ --- title: "The Studio" -description: "Preview every paywall locally with superwall dev — devices, themes, locales, live variables, and simulated purchases." +description: "Preview every paywall locally with superwall dev, devices, themes, locales, live variables, and simulated purchases." --- `superwall dev` hosts the studio at `http://localhost:6100`: every paywall in your project as a card with a live miniature, and an editor per paywall with a device-frame preview at exact logical size. It's where you check everything you can't check in code. @@ -13,23 +13,23 @@ superwall dev examples/* # several projects at once `dev` needs no login, regenerates `superwall.d.ts` first (so route and product types are always current), and takes `--port`/`-p` (default 6100, moving to the next free port) and `--host`. -Project problems — a stray file in `app/`, a duplicate route — print as warnings in dev. They're the same checks that block a push, so fix them as they appear rather than discovering them at ship time. +Project problems, a stray file in `app/`, a duplicate route, print as warnings in dev. They're the same checks that block a push, so fix them as they appear rather than discovering them at ship time. ## What you can check -- **Devices** — iPhone SE through iPad Pro, plus Pixel, Galaxy and Desktop, and a free-resize responsive mode. Switching devices also changes what the paywall sees as platform, model, and OS version, so platform-conditional code is testable too. -- **Light and dark** — the studio's theme toggle drives the same `dark` class the framework stamps from what the device reports. Check both, always. -- **Locale** — switch languages to proof every catalog. See [Localization](/framework/localization). -- **Rotation** — portrait and landscape, live. See `useDevice().orientation` in the [hooks reference](/framework/hooks). -- **Trial eligibility** — a toggle that flips the store's answer, so both versions of a trial paywall are one click apart. See [Free trials](/framework/trials). -- **Variables** — edit user attributes, device properties, placement params, and per-product variables live in the Variables panel. Values are seeded from your app's real sample data and products when you're logged in; without a login the panel falls back to built-in defaults. See [Variables & personalization](/framework/variables). +- **Devices**, iPhone SE through iPad Pro, plus Pixel, Galaxy and Desktop, and a free-resize responsive mode. Switching devices also changes what the paywall sees as platform, model, and OS version, so platform-conditional code is testable too. +- **Light and dark**. The studio's theme toggle drives the same `dark` class the framework stamps from what the device reports. Check both, always. +- **Locale**, switch languages to proof every catalog. See [Localization](/framework/localization). +- **Rotation**, portrait and landscape, live. See `useDevice().orientation` in the [hooks reference](/framework/hooks). +- **Trial eligibility**. A toggle that flips the store's answer, so both versions of a trial paywall are one click apart. See [Free trials](/framework/trials). +- **Variables**, edit user attributes, device properties, placement params, and per-product variables live in the Variables panel. Values are seeded from your app's real sample data and products when you're logged in; without a login the panel falls back to built-in defaults. See [Variables & personalization](/framework/variables). ## Simulated outcomes -In dev, most things that would normally resolve from the host — purchases, permission prompts, callbacks — prompt **you** to pick the outcome instead, so both branches of every flow are testable. Decline your own purchase to check the abandoned path; deny your own permission request to check the fallback copy. +In dev, most things that would normally resolve from the host, purchases, permission prompts, callbacks, prompt **you** to pick the outcome instead, so both branches of every flow are testable. Decline your own purchase to check the abandoned path; deny your own permission request to check the fallback copy. -Actions the paywall sends to the host — `close()`, `openUrl()` — surface as toasts as they happen, so you can confirm one reached the host. Haptics, page views and purchase messages are deliberately silent. +Actions the paywall sends to the host (`close()`, `openUrl()`) surface as toasts as they happen, so you can confirm one reached the host. Haptics, page views and purchase messages are deliberately silent. ## Dev vs device @@ -38,16 +38,16 @@ The same paywall runs against a simulated host in dev and the real SDK on device | | `superwall dev` | Real device | | --- | --- | --- | | Product variables | `undefined` until the studio injects your dashboard products | Delivered by the SDK | -| `purchase()` | Simulated — you pick the outcome | Real store | +| `purchase()` | Simulated, you pick the outcome | Real store | | `restore()` | Always succeeds | Real store | | `close()`, `openUrl()` | Toast in the studio (haptics are silent) | Acted on by the host | | Permissions / callbacks | Studio prompts you | OS prompt / your app's code | -| Numeric **product** variables | Numbers | **Strings** — `Number()` before arithmetic | +| Numeric **product** variables | Numbers | **Strings**, `Number()` before arithmetic | | Presentation (`paywall_open`) | Immediate | After preload, when actually shown | -| Web checkout sheet | Not mounted — verify on a pushed version | Works | +| Web checkout sheet | Not mounted, verify on a pushed version | Works | -A published paywall never falls back to simulated data — the simulation exists only in previews. +A published paywall never falls back to simulated data, the simulation exists only in previews. ## The Push, Publish, and Promote buttons -The studio has buttons for the same operations as the CLI — good for quick iteration. Either way, pushing needs the `headless_paywalls` feature enabled on your Superwall application; it's a server-side flag, so ask your Superwall contact if a push comes back refused. For actually shipping, prefer the CLI: the buttons skip the diagnostics gate and the dashboard product check, can't resolve renames, and take no `-m` note. See [Push, promote & publish](/framework/push-and-promote). +The studio has buttons for the same operations as the CLI, good for quick iteration. Either way, pushing needs the `headless_paywalls` feature enabled on your Superwall application; it's a server-side flag, so ask your Superwall contact if a push comes back refused. For actually shipping, prefer the CLI: the buttons skip the diagnostics gate and the dashboard product check, can't resolve renames, and take no `-m` note. See [Push, promote & publish](/framework/push-and-promote). diff --git a/content/docs/framework/styling.mdx b/content/docs/framework/styling.mdx index 20060a15..ce53d844 100644 --- a/content/docs/framework/styling.mdx +++ b/content/docs/framework/styling.mdx @@ -1,9 +1,9 @@ --- title: "Styling" -description: "How styling works in a paywall — plain CSS, the color-scheme class, the background color, safe areas, and the platform stylesheet Superwall applies at serve time." +description: "How styling works in a paywall, plain CSS, the color-scheme class, the background color, safe areas, and the platform stylesheet Superwall applies at serve time." --- -A paywall is styled with ordinary CSS. The framework ships no component library, no theme, and no opinions about how your paywall should look — your stylesheet is the whole story. +A paywall is styled with ordinary CSS. The framework ships no component library, no theme, and no opinions about how your paywall should look. Your stylesheet is the whole story. What the framework does provide is a small set of mechanisms your CSS can rely on: a color-scheme class that follows the device, a background color that reaches the native SDK, and a platform stylesheet applied at serve time. @@ -48,9 +48,25 @@ background: { light: "#ffffff", dark: "#0d0f12" } One value covers both sides of the load: it paints the page background, and it's sent to the native SDK, which paints the same color behind the webview and derives its loading spinner from it. Set it to whatever your page background is, and a native paywall has no flash of a different color while it loads. On the web the document paints the light color until React mounts, so a dark-mode visitor to a web funnel can still see one. +### Pages need `--sw-background` too + +`background` covers the load: the document before React mounts, and the native color behind the webview. It does **not** paint the pages themselves. Every route is opaque so that a page sliding in never shows the stack through it, and each one paints `--sw-background`, which defaults to the system `Canvas` color. + +Set both, to the same color, or your configured background is covered by system white or black on every route: + +```css +:root { + --bg: #fdfef6; + --sw-background: var(--bg); /* what the pages paint */ +} +:root.dark { --bg: #1c1b19; } +``` + +Every [example project](/framework/examples) and the `superwall create` scaffold set it this way. Set `--sw-background: transparent` only when the paywall is meant to show what is behind it. + ## Safe areas -The standard `env(safe-area-inset-*)` variables work on device. They resolve to **0** in previews and in some webview contexts, so bare `env()` math collapses to nothing exactly where it matters — a close button lands in the status bar, a CTA under the home indicator. +The standard `env(safe-area-inset-*)` variables work on device. They resolve to **0** in previews and in some webview contexts, so bare `env()` math collapses to nothing exactly where it matters, a close button lands in the status bar, a CTA under the home indicator. Wrap them in `max()` with a floor so the layout survives either case: @@ -61,18 +77,18 @@ padding-bottom: max(calc(env(safe-area-inset-bottom, 0px) + 14px), 28px); ## Scrolling -Scroll behavior belongs to the platform. `scrollEnabled` in [config](/framework/config) turns page scrolling on or off, and the platform stylesheet implements it — so let the page itself scroll rather than building nested scroll containers. +Scroll behavior belongs to the platform. `scrollEnabled` in [config](/framework/config) turns page scrolling on or off, and the platform stylesheet implements it, so let the page itself scroll rather than building nested scroll containers. ## Fonts -The system font stack is what makes a webview read as native. When a design calls for brand type, a relative-path `@font-face` is the entire setup — see [custom fonts in Assets](/framework/assets#custom-fonts). +The system font stack is what makes a webview read as native. When a design calls for brand type, a relative-path `@font-face` is the entire setup. See [custom fonts in Assets](/framework/assets#custom-fonts). ## The platform stylesheet -Published paywalls receive a small Superwall-owned stylesheet at serve time, carrying platform-wide behavior like scroll control. It lands *before* your styles in the cascade, so your ordinary declarations win. A few platform rules are `!important` — `box-sizing` and `cursor` among them — and need `!important` of your own to override. Previews apply the same stylesheet, which is what makes local and published render identically. +Published paywalls receive a small Superwall-owned stylesheet at serve time, carrying platform-wide behavior like scroll control. It lands *before* your styles in the cascade, so your ordinary declarations win. A few platform rules are `!important`, `box-sizing` and `cursor` among them, and need `!important` of your own to override. Previews apply the same stylesheet, which is what makes local and published render identically. Set `SUPERWALL_RUNTIME_URL` in the project `.env` only if you need previews to use a local build of that platform layer. ## Check it in the studio -The [studio](/framework/studio) renders a paywall at device sizes with the controls a device would supply — a theme toggle for both color schemes, presets from phone through desktop (or any width in responsive mode), and a trial-eligibility toggle. It's the fastest way to see a stylesheet behave under conditions your browser won't reproduce on its own. +The [studio](/framework/studio) renders a paywall at device sizes with the controls a device would supply, a theme toggle for both color schemes, presets from phone through desktop (or any width in responsive mode), and a trial-eligibility toggle. It's the fastest way to see a stylesheet behave under conditions your browser won't reproduce on its own. diff --git a/content/docs/framework/transitions.mdx b/content/docs/framework/transitions.mdx index c6cc172d..a2e6fac7 100644 --- a/content/docs/framework/transitions.mdx +++ b/content/docs/framework/transitions.mdx @@ -3,15 +3,15 @@ title: "Transitions" description: "Built-in page transitions, where to set them, and how to define your own with nothing but a name and CSS." --- -Navigation animates by default. The framework ships five built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases — no registration, no JavaScript. +Navigation animates by default. The framework ships five built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases, no registration, no JavaScript. ## Built-ins -- **`push`** — iOS-style: the new page slides in from the right while the one behind shifts back and dims. The default. -- **`slide`** — both pages travel: the new one slides in from the right as the current one slides out to the left. -- **`fade`** — a crossfade, one layer at a time. -- **`shift`** — the funnel step: the new page fades in as it drifts the last 44px into place, and the page it replaces is simply gone. One layer moves at a time, so a long flow never reads as a stack. Set it once in `config.ts` for onboarding quizzes and web funnels. -- **`none`** — instant. +- **`push`**, iOS-style: the new page slides in from the right while the one behind shifts back and dims. The default. +- **`slide`**, both pages travel: the new one slides in from the right as the current one slides out to the left. +- **`fade`**, a crossfade, one layer at a time. +- **`shift`**, the funnel step: the new page fades in as it drifts the last 44px into place, and the page it replaces is simply gone. One layer moves at a time, so a long flow never reads as a stack. Set it once in `config.ts` for onboarding quizzes and web funnels. +- **`none`**, instant. ## Set them at three levels @@ -23,11 +23,11 @@ export const transition = "fade"; // one page (top of its file) export default definePaywall({ transition: "slide" }); // whole surface ``` -Going forward uses the *incoming* page's transition; going back uses the *leaving* one's — so a page always leaves the way it arrived. +Going forward uses the *incoming* page's transition; going back uses the *leaving* one's, so a page always leaves the way it arrived. ## Tune the built-ins -A handful of CSS variables adjust timing and feel without replacing anything. Set them on `[data-sw-route]`, not `:root` — the router writes `--sw-transition` inline on the routes container, and an inline declaration shadows `:root`: +A handful of CSS variables adjust timing and feel without replacing anything. Set them on `[data-sw-route]`, not `:root`. The router writes `--sw-transition` inline on the routes container, and an inline declaration shadows `:root`: ```css [data-sw-route] { @@ -39,7 +39,7 @@ A handful of CSS variables adjust timing and feel without replacing anything. Se } ``` -All motion respects `prefers-reduced-motion` automatically — with reduced motion on, the router settles instantly. +All motion respects `prefers-reduced-motion` automatically, with reduced motion on, the router settles instantly. ## Custom transitions @@ -78,14 +78,14 @@ The four phases cover both directions of travel: ### Rules for custom transitions -- **Always start `from` at `var(--sw-from-transform, )`** — and `--sw-from-filter` for filters. The router fills these with a page's live position when a navigation interrupts an animation, so a spammed button picks the page up where it stands instead of snapping. +- **Always start `from` at `var(--sw-from-transform, )`**, and `--sw-from-filter` for filters. The router fills these with a page's live position when a navigation interrupts an animation, so a spammed button picks the page up where it stands instead of snapping. - **Wrap in `prefers-reduced-motion: no-preference`.** With reduced motion on, the router settles instantly and your animation never runs. - **Duration comes from your CSS.** The page stays mounted as long as its animation runs, capped at 5s (and falling back to 500ms when nothing measurable is declared); don't declare a duration anywhere else. -- **Omit phases you don't want.** They don't animate — that's how `fade` crossfades one layer at a time. +- **Omit phases you don't want.** They don't animate. That's how `fade` crossfades one layer at a time. ## Bottom sheets over the flow -For a modal-feeling page — a last-chance offer, say — define a `sheet` transition: the page slides up while the one behind scales back and dims. Darken the container behind it in the same motion by reusing the framework's timing variables: +For a modal-feeling page, a last-chance offer, say, define a `sheet` transition: the page slides up while the one behind scales back and dims. Darken the container behind it in the same motion by reusing the framework's timing variables: ```css :root { --dim: 0.85; } @@ -99,8 +99,8 @@ For a modal-feeling page — a last-chance offer, say — define a `sheet` trans } ``` -One `--dim` number drives both the page's `brightness()` and the backdrop, so they always match. Dismissing the sheet is `router.back()` — the page leaves the way it came. The abandonment offer example has the full recipe — see [Examples](/framework/examples). +One `--dim` number drives both the page's `brightness()` and the backdrop, so they always match. Dismissing the sheet is `router.back()`, the page leaves the way it came. The abandonment offer example has the full recipe, see [Examples](/framework/examples). ## Transitions vs. in-page animation -Animation libraries (Motion, plain CSS) animate *inside* a page. Moving *between* pages stays the router's job. Keep that line and spamming navigation can never fight your component animations — and remember that entry animations gate on presentation, never mount. See [Lifecycle & events](/framework/lifecycle). +Animation libraries (Motion, plain CSS) animate *inside* a page. Moving *between* pages stays the router's job. Keep that line and spamming navigation can never fight your component animations, and remember that entry animations gate on presentation, never mount. See [Lifecycle & events](/framework/lifecycle). diff --git a/content/docs/framework/trials.mdx b/content/docs/framework/trials.mdx index 01a1098a..e1d1de09 100644 --- a/content/docs/framework/trials.mdx +++ b/content/docs/framework/trials.mdx @@ -3,7 +3,7 @@ title: "Free Trials" description: "Fork your paywall on trial eligibility the store reports, pull trial terms from product variables, and remind users before a trial ends." --- -The store decides who gets a trial — not you, and not the user's claim. Someone who used their trial two years ago and reinstalled is ineligible, and only the store knows. `useTrialEligibility()` is that signal, and it splits your paywall into two versions that must **both** read as intentional. +The store decides who gets a trial, not you, and not the user's claim. Someone who used their trial two years ago and reinstalled is ineligible, and only the store knows. `useTrialEligibility()` is that signal, and it splits your paywall into two versions that must **both** read as intentional. ## Read eligibility @@ -13,7 +13,7 @@ import { useTrialEligibility } from "superwall/hooks"; const { eligible } = useTrialEligibility(); // boolean | undefined ``` -`eligible` is `undefined` until the SDK reports, so gate trial-only UI on `eligible === true` — never on "not false." +`eligible` is `undefined` until the SDK reports, so gate trial-only UI on `eligible === true`, never on "not false." ## Two paywalls in one @@ -36,29 +36,29 @@ const days = annual?.variables.trialPeriodDays; Two details in that snippet are deliberate: -- **The fallbacks nest.** Eligible-but-days-unknown gets its own sentence — the data may not have arrived yet, and "undefined days free" is never acceptable copy. +- **The fallbacks nest.** Eligible-but-days-unknown gets its own sentence. The data may not have arrived yet, and "undefined days free" is never acceptable copy. - **The ineligible side is written, not defaulted.** "You have used your trial" tells a returning customer the paywall knows who they are. ## Trial terms come from the product -Trial length, price, and end date are variables on the product — `trialPeriodDays`, `trialPeriodPrice`, `trialPeriodEndDate`, `trialPeriodText` — never values in your files. They follow the same rules as every product read: guard them, and `Number()` before arithmetic. See [Products](/framework/products). +Trial length, price, and end date are variables on the product, `trialPeriodDays`, `trialPeriodPrice`, `trialPeriodEndDate`, `trialPeriodText`, never values in your files. They follow the same rules as every product read: guard them, and `Number()` before arithmetic. See [Products](/framework/products). ## Test both sides -- **The studio** has a trial-eligibility toggle — flip it and check every string on both sides. See [The studio](/framework/studio). +- **The studio** has a trial-eligibility toggle, flip it and check every string on both sides. See [The studio](/framework/studio). - **Config can force either side** while you're building: ```ts introductoryOfferEligibility: "alwaysEligible" | "alwaysIneligible" // default "automatic" ``` -Leave it on `"automatic"` for production — that lets the store decide. +Leave it on `"automatic"` for production. That lets the store decide. The `trial-eligibility` [example](/framework/examples) is the reference: every string forks, and both states read as designed. ## Trial reminder notifications -Declare a local notification in `config.ts` and the SDK schedules it when a trial **actually starts** — the paywall doesn't need to be open when it fires: +Declare a local notification in `config.ts` and the SDK schedules it when a trial **actually starts**. The paywall doesn't need to be open when it fires: ```ts notifications: { @@ -70,9 +70,9 @@ notifications: { }, ``` -`title`, `subtitle`, and `body` accept message keys (resolved through `t()` — see [Localization](/framework/localization)) or literal copy. +`title`, `subtitle`, and `body` accept message keys (resolved through `t()`, see [Localization](/framework/localization)) or literal copy. -For full control, pass a function instead. It receives `{ trialEndDate, productIdentifier, product, t, locale }` and returns `{ title, subtitle?, body, delayMs }` — or `null` to skip the notification entirely: +For full control, pass a function instead. It receives `{ trialEndDate, productIdentifier, product, t, locale }` and returns `{ title, subtitle?, body, delayMs }`, or `null` to skip the notification entirely: ```ts notifications: { @@ -84,10 +84,10 @@ notifications: { }, ``` -`delayMs` is measured from now, and a value of `0` or less is skipped rather than fired immediately — compute it from `trialEndDate`, which is always supplied. +`delayMs` is measured from now, and a value of `0` or less is skipped rather than fired immediately, compute it from `trialEndDate`, which is always supplied. The `trial-reminders` [example](/framework/examples) shows both forms. -Users warned before the charge cancel calmly instead of charging back — and the ones who stay chose to stay. +Users warned before the charge cancel calmly instead of charging back, and the ones who stay chose to stay. diff --git a/content/docs/framework/troubleshooting.mdx b/content/docs/framework/troubleshooting.mdx index 712be441..3cffab4c 100644 --- a/content/docs/framework/troubleshooting.mdx +++ b/content/docs/framework/troubleshooting.mdx @@ -1,6 +1,6 @@ --- title: "Troubleshooting" -description: "Common CLI errors and runtime surprises — what each one means and how to fix it." +description: "Common CLI errors and runtime surprises, what each one means and how to fix it." --- The most common failures, in two groups: errors the CLI prints, and runtime behavior that surprises people the first time. @@ -9,11 +9,11 @@ The most common failures, in two groups: errors the CLI prints, and runtime beha ### `Not a superwall project` -The CLI couldn't find a project from where you ran it. Run commands from your app root or from inside `superwall/` — and check that the project's `package.json` depends on `superwall`. See [Project structure](/framework/project-structure). +The CLI couldn't find a project from where you ran it. Run commands from your app root or from inside `superwall/`, and check that the project's `package.json` depends on `superwall`. See [Project structure](/framework/project-structure). ### `…package.json is named "superwall"` -Your project's `package.json` has `"name": "superwall"`, which shadows the framework import — nothing in the project can `import` from `superwall` anymore. Rename the package; `superwall create` names it after your app for exactly this reason. +Your project's `package.json` has `"name": "superwall"`, which shadows the framework import. Nothing in the project can `import` from `superwall` anymore. Rename the package; `superwall create` names it after your app for exactly this reason. ### `No superwall framework found` @@ -21,11 +21,11 @@ The project exists but its dependencies aren't installed, or `superwall` isn't a ### `This product / These N products do not exist on Superwall` -A `config.ts` names a product identifier the dashboard has no product for. The push refuses because every variable on that product would be undefined on device. Either fix the identifier, or create the products — from the dashboard, or with `superwall products create` from the CLI. See [Products](/framework/products) and the [CLI reference](/framework/cli). +A `config.ts` names a product identifier the dashboard has no product for. The push refuses because every variable on that product would be undefined on device. Either fix the identifier, or create the products, from the dashboard, or with `superwall products create` from the CLI. See [Products](/framework/products) and the [CLI reference](/framework/cli). ### `Headless paywalls are not enabled for this application` -The framework requires the headless paywalls feature on your Superwall application. It's a server-side flag — nothing in the CLI can set it. The account owner needs to have it enabled; contact us if it isn't. +The framework requires the headless paywalls feature on your Superwall application. It's a server-side flag. Nothing in the CLI can set it. The account owner needs to have it enabled; contact us if it isn't. ### `Multiple projects found. Pass --project .` @@ -33,9 +33,9 @@ Your account has several Superwall projects, and the command can't guess which o ### Diagnostics block the push -Publishing is immutable, so a paywall with diagnostics — a stray non-page file in `app/`, a duplicate route — refuses to push. The message names each file and where it belongs. These are the same warnings `superwall dev` prints, so you'll usually have seen them before push time. +Publishing is immutable, so a paywall with diagnostics, a stray non-page file in `app/`, a duplicate route, refuses to push. The message names each file and where it belongs. These are the same warnings `superwall dev` prints, so you'll usually have seen them before push time. -A directory missing `app/index.tsx` or `config.ts` is the exception: it isn't a paywall yet, so push skips it silently instead of failing. If a paywall you expected didn't ship, check that both files exist — `superwall dev` lists what it found. +A directory missing `app/index.tsx` or `config.ts` is the exception: it isn't a paywall yet, so push skips it silently instead of failing. If a paywall you expected didn't ship, check that both files exist. `superwall dev` lists what it found. ### Rename ambiguity in CI @@ -43,11 +43,11 @@ A renamed paywall directory can't be resolved interactively in CI, so the push s ### `paywall x has never been pushed` (promote) -Promote only moves the live pointer between pushed versions — there's nothing to point at yet. Push first. +Promote only moves the live pointer between pushed versions. There's nothing to point at yet. Push first. ### `superwall publish requires git` -The source snapshot is part of every push, so `push` and `publish` both need git — the message names `publish` whichever you ran. Install git. +The source snapshot is part of every push, so `push` and `publish` both need git, the message names `publish` whichever you ran. Install git. ### `Pushing paywalls needs a Superwall account` @@ -57,7 +57,7 @@ Run `superwall login` once interactively, or set `SUPERWALL_API_KEY` (an `sk_… ### Prices are undefined in dev -Expected. In `superwall dev`, product variables are `undefined` until the studio injects your dashboard's products — which is why every read is guarded and the unpriced state is designed, not accidental. The reading rules are in [Products](/framework/products). +Expected. In `superwall dev`, product variables are `undefined` until the studio injects your dashboard's products, which is why every read is guarded and the unpriced state is designed, not accidental. The reading rules are in [Products](/framework/products). ### A number comparison works in dev but not on device @@ -65,11 +65,11 @@ Numeric-looking variables are numbers in dev but **strings on a real device** (` ### My entry animation already finished when the paywall appears -The SDK preloads paywalls hidden, so components mount long before anyone is looking — a mount-timed animation plays to an empty room. Gate entry animations on presentation, not mount. See [Lifecycle & events](/framework/lifecycle). +The SDK preloads paywalls hidden, so components mount long before anyone is looking, a mount-timed animation plays to an empty room. Gate entry animations on presentation, not mount. See [Lifecycle & events](/framework/lifecycle). ### Dark mode looks right on my machine, wrong on device -The mechanism is the `dark` class the framework maintains on `` — not `prefers-color-scheme`. A media query can't see what the device reports and ignores the studio's theme toggle. Style off the class, as shown in [Styling](/framework/styling). +The mechanism is the `dark` class the framework maintains on ``, not `prefers-color-scheme`. A media query can't see what the device reports and ignores the studio's theme toggle. Style off the class, as shown in [Styling](/framework/styling). ### My link does nothing @@ -81,4 +81,4 @@ Inside a webview, an `` either does nothing or navigates the paywall awa ### The payment sheet doesn't open in dev -By design — `superwall dev` previews the flow and copy but doesn't mount the web checkout payment sheet. Push and open the live URL to verify the checkout itself. See [Web checkout](/framework/web-checkout). +By design, `superwall dev` previews the flow and copy but doesn't mount the web checkout payment sheet. Push and open the live URL to verify the checkout itself. See [Web checkout](/framework/web-checkout). diff --git a/content/docs/framework/variables.mdx b/content/docs/framework/variables.mdx index 9b938329..4e90c37c 100644 --- a/content/docs/framework/variables.mdx +++ b/content/docs/framework/variables.mdx @@ -1,9 +1,9 @@ --- title: "Variables & Personalization" -description: "React to user attributes, device state, and placement parameters — and write paywalls the dashboard can experiment on without a rebuild." +description: "React to user attributes, device state, and placement parameters, and write paywalls the dashboard can experiment on without a rebuild." --- -Everything your app and the SDK tell a paywall about the presentation arrives through `useVariables()`: who the user is, what device they're on, and what the placement was called with. Read these defensively and a single paywall can greet a returning user by name, adapt to platform, or react to any parameter your app passes — all without a rebuild. +Everything your app and the SDK tell a paywall about the presentation arrives through `useVariables()`: who the user is, what device they're on, and what the placement was called with. Read these defensively and a single paywall can greet a returning user by name, adapt to platform, or react to any parameter your app passes, all without a rebuild. ## `useVariables()` @@ -15,9 +15,9 @@ const { device, user, params } = useVariables(); Three records, three sources: -- **`device`** — filled in by the SDK: `platform`, `deviceModel`, `osVersion`, `appVersion`, `deviceLocale`, `regionCode`, `deviceCurrencyCode`, `subscriptionStatus`, `activeEntitlements`, `daysSinceInstall`, `totalPaywallViews`, and more. -- **`user`** — whatever your app set via `setUserAttributes` (`user.firstName`, `user.plan`, …). -- **`params`** — whatever the placement was called with (`params.event_name` is the placement's name; `$`-prefixed keys are SDK-set, and anything the app passed alongside comes through unprefixed). +- **`device`**, filled in by the SDK: `platform`, `deviceModel`, `osVersion`, `appVersion`, `deviceLocale`, `regionCode`, `deviceCurrencyCode`, `subscriptionStatus`, `activeEntitlements`, `daysSinceInstall`, `totalPaywallViews`, and more. +- **`user`**, whatever your app set via `setUserAttributes` (`user.firstName`, `user.plan`, …). +- **`params`**, whatever the placement was called with (`params.event_name` is the placement's name; `$`-prefixed keys are SDK-set, and anything the app passed alongside comes through unprefixed). ```tsx const name = typeof user.firstName === "string" ? user.firstName : undefined; @@ -28,16 +28,16 @@ const name = typeof user.firstName === "string" ? user.firstName : undefined; ## Guard every read -All three records are filled in by the host — your paywall controls none of them, so every read needs a fallback: +All three records are filled in by the host, your paywall controls none of them, so every read needs a fallback: -- For **`device`** fields, `?? "—"` (or any sensible default) suffices — the SDK guarantees the shape, just not that a value has arrived yet. +- For **`device`** fields, `?? "—"` (or any sensible default) suffices. The SDK guarantees the shape, just not that a value has arrived yet. - For **`user`** and **`params`**, the host controls the *type* too, so check it before using it: `typeof params.event_name === "string"`. An attribute your app sets as a number today might be a string tomorrow, and the paywall must not crash either way. `device.isSandbox` is a string, not a boolean. Compare it as one. -While previewing, every one of these values is editable live in the studio's **Variables** panel — user attributes, device properties, placement params, and per-product variables — seeded from your app's real sample data. Change a value and watch the paywall react. See [The studio](/framework/studio). +While previewing, every one of these values is editable live in the studio's **Variables** panel: user attributes, device properties, placement params, and per-product variables, all seeded from your app's real sample data. Change a value and watch the paywall react. See [The studio](/framework/studio). ## `useUser()` @@ -49,7 +49,7 @@ import { useUser } from "superwall/hooks"; const user = useUser(); ``` -Identical to `useVariables().user` — reach for it when the device and params records aren't needed. +Identical to `useVariables().user`, reach for it when the device and params records aren't needed. ## `useDevice()` @@ -61,13 +61,13 @@ import { useDevice } from "superwall/hooks"; const { orientation, platform, deviceModel } = useDevice(); ``` -`orientation` is `"portrait" | "landscape"`, measured in the page itself — it updates the moment the device turns, so you can build layouts that answer to rotation. The orientation example reflows to a two-column grid in landscape rather than shrinking the portrait layout; see [Examples](/framework/examples). +`orientation` is `"portrait" | "landscape"`, measured in the page itself, it updates the moment the device turns, so you can build layouts that answer to rotation. The orientation example reflows to a two-column grid in landscape rather than shrinking the portrait layout; see [Examples](/framework/examples). ## Built to be experimented on -Notice what's missing: variables are never *declared* in code. What the paywall reads — user attributes, device state, placement params, product variables, trial eligibility — is supplied by the app and the store at runtime, and the studio overrides all of it live while previewing. +Notice what's missing: variables are never *declared* in code. What the paywall reads, user attributes, device state, placement params, product variables, trial eligibility, is supplied by the app and the store at runtime, and the studio overrides all of it live while previewing. -Write every read defensively — guarded, typed, with a designed fallback — and every one of those values becomes a knob the dashboard can turn without a rebuild. A paywall that renders sensibly for any combination of inputs can be A/B tested freely. +Write every read defensively, guarded, typed, with a designed fallback, and every one of those values becomes a knob the dashboard can turn without a rebuild. A paywall that renders sensibly for any combination of inputs can be A/B tested freely. The personalization example shows the full doctrine in one project: `?? "—"` for SDK-guaranteed device fields, `typeof` checks for host-controlled user and params reads, and designed fallbacks for every string. See [Examples](/framework/examples). diff --git a/content/docs/framework/web-checkout.mdx b/content/docs/framework/web-checkout.mdx index 8bb62132..4f85a5ec 100644 --- a/content/docs/framework/web-checkout.mdx +++ b/content/docs/framework/web-checkout.mdx @@ -1,6 +1,6 @@ --- title: "Web Checkout" -description: "Sell the same paywall on the web with one config key — Stripe payment in a sheet, Apple Pay, or a hosted checkout page, with purchase() unchanged." +description: "Sell the same paywall on the web with one config key, Stripe payment in a sheet, Apple Pay, or a hosted checkout page, with purchase() unchanged." --- One config key sells the same paywall on the web: @@ -9,19 +9,19 @@ One config key sells the same paywall on the web: checkout: "sheet", ``` -Native hosts ignore it — drop the same paywall into your iOS app and it buys through the App Store. Your components don't change, and neither does `purchase()`. +Native store products ignore it. Drop the same paywall into your iOS app and a store reference buys through the App Store. Your components don't change, and neither does `purchase()`. One exception: in `external` mode the page navigates away to the hosted checkout page, so `purchase()` never resolves. Call it, but don't `await` it or branch on its result. -This page covers the framework side: config, modes, and prefetching. Stripe keys, web apps, products, and campaigns are set up in the dashboard — see the [Web Checkout](/web-checkout) section for that half. +This page covers the framework side: config, modes, and prefetching. Stripe keys, web apps, products, and campaigns are set up in the dashboard, see the [Web Checkout](/web-checkout) section for that half. ## Modes | Mode | The purchase | Use when | | --- | --- | --- | -| `sheet` | Stripe checkout in a sheet **over the paywall** — nobody leaves mid-flow | The default choice for the web | +| `sheet` | Stripe checkout in a sheet **over the paywall**, nobody leaves mid-flow | The default choice for the web | | `applePay` | Straight to Apple Pay where available, sheet as fallback | Apple-Pay-heavy audiences | | `external` | Superwall's hosted checkout page, then back | You want zero payment UI in the paywall | @@ -29,7 +29,7 @@ Only `sheet` and `applePay` add payment UI to the paywall; `external` adds nothi ## Products -Web paywalls sell Stripe products, declared with the price inside the identifier — `{environment}:{priceId}:{offer}`, where `{environment}` is exactly `test` or `live`: +Web paywalls sell Stripe products, declared with the price inside the identifier, `{environment}:{priceId}:{offer}`, where `{environment}` is exactly `test` or `live`: ```ts products: { @@ -41,17 +41,17 @@ A paywall can declare store and Stripe products side by side. See [Products](/fr ## The purchase, unchanged -With `sheet` or `applePay` and a Stripe product, the same `purchase()` call opens the payment sheet in-page — a brief loading overlay covers the session creation unless it was prefetched. The outcomes map exactly as they do natively: +With `sheet` or `applePay` and a Stripe product, the same `purchase()` call opens the payment sheet in-page, a brief loading overlay covers the session creation unless it was prefetched. The outcomes map exactly as they do natively: -- `completed` — payment succeeded -- `abandoned` — the shopper closed the sheet -- `failed` — a payment or session error +- `completed`, payment succeeded +- `abandoned`, the shopper closed the sheet +- `failed`, a payment or session error -The web sheet does not set `isPurchasing` — react to the awaited result, which is the right pattern everywhere anyway. A web paywall also typically drops the close button and restore link its native sibling carries: there's no host app to close back to. +The web sheet does not set `isPurchasing`, react to the awaited result, which is the right pattern everywhere anyway. A web paywall also typically drops the close button and restore link its native sibling carries: there's no host app to close back to. -## Prefetch — make the sheet open instantly +## Prefetch: make the sheet open instantly Creating a checkout session takes a network round-trip. Prefetching does it before the tap, so the sheet opens with nothing to wait for. @@ -62,7 +62,7 @@ checkout: { mode: "sheet", prefetch: "pro" } // which product warms first checkout: { mode: "sheet", prefetch: false } // disable auto-prefetch ``` -**On selection — do this whenever there's a product selector.** With `sheet`, only one plan is warmed; prefetch the selected one so whichever plan is on screen opens instantly: +**On selection, do this whenever there's a product selector.** With `sheet`, only one plan is warmed; prefetch the selected one so whichever plan is on screen opens instantly: ```tsx import { usePurchase, type ProductReference } from "superwall/hooks"; @@ -75,18 +75,18 @@ React.useEffect(() => { }, [prefetch, reference]); ``` -`prefetch` is safe to call unconditionally — it's a no-op for store products, for paywalls without web checkout, and for already-warm sessions (sessions stay warm for about ten minutes). It's a hint; never await it. +`prefetch` is safe to call unconditionally. It's a no-op for store products, for paywalls without web checkout, and for already-warm sessions (sessions stay warm for about ten minutes). It's a hint; never await it. ## The sheet is not yours to style -It takes no colors, fonts, or spacing from the page around it, and there's no prop to change that. This is deliberate: payment UI that borrows the paywall's design stops looking like payment UI — and the payment step is the one place a shopper is entitled to see something they recognize. Safe areas, scroll locking, and Escape handling (never mid-payment) are handled for you. +It takes no colors, fonts, or spacing from the page around it, and there's no prop to change that. This is deliberate: payment UI that borrows the paywall's design stops looking like payment UI, and the payment step is the one place a shopper is entitled to see something they recognize. Safe areas, scroll locking, and Escape handling (never mid-payment) are handled for you. ## Verify on a pushed version -`superwall dev` previews the flow and the copy, but it does not mount the payment sheet. Push and open the live URL to verify the checkout itself — see [Push, promote & publish](/framework/push-and-promote). +`superwall dev` previews the flow and the copy, but it does not mount the payment sheet. Push and open the live URL to verify the checkout itself, see [Push, promote & publish](/framework/push-and-promote). -Two things gate that push: the Stripe product must already be imported into your Superwall dashboard (push validates every declared identifier, Stripe ones included), and the application needs the `headless_paywalls` feature enabled — see the [CLI reference](/framework/cli). +Two things gate that push: the Stripe product must already be imported into your Superwall dashboard (push validates every declared identifier, Stripe ones included), and the application needs the `headless_paywalls` feature enabled, see the [CLI reference](/framework/cli). ## A full web funnel -The `web-funnel` [example](/framework/examples) is the reference: question steps as pages, a typed plan selector with on-selection prefetch, then `purchase(reference)` — the whole flow in one paywall. Because `checkout` is set, the flow's step and answers live in the page URL, so it resumes from any link — in Safari after an in-app browser, or back from hosted checkout. Keep every answer in `useQueryState` — [Web Funnels](/framework/web-funnels) is the guide. +The `web-funnel` [example](/framework/examples) is the reference: question steps as pages, a typed plan selector with on-selection prefetch, then `purchase(reference)`, the whole flow in one paywall. Because `checkout` is set, the flow's step and answers live in the page URL, so it resumes from any link, in Safari after an in-app browser, or back from hosted checkout. Keep every answer in `useQueryState`. [Web Funnels](/framework/web-funnels) is the guide. diff --git a/content/docs/framework/web-funnels.mdx b/content/docs/framework/web-funnels.mdx index 6e53d316..0dfa1a31 100644 --- a/content/docs/framework/web-funnels.mdx +++ b/content/docs/framework/web-funnels.mdx @@ -3,13 +3,13 @@ title: "Web Funnels" description: "Quizzes and checkout funnels on the web: one paywall whose steps are pages, every answer kept in the URL so the flow survives any browser hand-off, and payment at the end." --- -A web funnel is a paywall with `checkout` set: a few question pages, a plan, then `purchase()`. It is served as a normal web page — and a web page has one problem a native paywall never has: **the person may change browsers halfway through.** A link opened from Instagram or TikTok runs in that app's in-app browser; tapping "Open in Safari" (or being sent there to pay with Apple Pay) hands over the URL and nothing else. `localStorage`, cookies, React state — all of it stays behind. Hosted checkout comes back to a URL too, and a reload starts from scratch. +A web funnel is a paywall with `checkout` set: a few question pages, a plan, then `purchase()`. It is served as a normal web page, and a web page has one problem a native paywall never has: **the person may change browsers halfway through.** A link opened from Instagram or TikTok runs in that app's in-app browser; tapping "Open in Safari" (or being sent there to pay with Apple Pay) hands over the URL and nothing else. `localStorage`, cookies, React state, all of it stays behind. Hosted checkout comes back to a URL too, and a reload starts from scratch. So a web funnel keeps its state in the URL. The router does its half automatically; your half is one rule. ## The rule: every answer is `useQueryState` -On a web funnel, **never hold an answer in `useState`, layout context, or a module.** Single choice, multi choice, text input, the selected plan — anything the person entered lives in `useQueryState`, so any URL resumes the flow on the same step with the same answers. +On a web funnel, **never hold an answer in `useState`, layout context, or a module.** Single choice, multi choice, text input, the selected plan, anything the person entered lives in `useQueryState`, so any URL resumes the flow on the same step with the same answers. ```tsx import { parseAsArrayOf, parseAsStringEnum, useQueryState } from "superwall/navigation"; @@ -49,12 +49,12 @@ const [name, setName] = useQueryState("name"); const [plan, setPlan] = useQueryState("plan", parseAsStringEnum(["monthly", "annual"]).withDefault("annual")); ``` -Every later page reads the same hook — the plan page shows `goal`, the summary page lists `interests`, and `purchase(plan)` uses the selection — with no context and no prop drilling. Guard reads on pages someone might land on directly: `goal ? COPY[goal] : COPY.default`. +Every later page reads the same hook: the plan page shows `goal`, the summary page lists `interests`, and `purchase(plan)` uses the selection, all with no context and no prop drilling. Guard reads on pages someone might land on directly: `goal ? COPY[goal] : COPY.default`. The API is [nuqs](https://nuqs.dev)'s, so its parsers read the same: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsStringEnum`, `parseAsArrayOf`, `createParser`, each with `.withDefault()` (removes `null` from the type and clears the key when the value equals the default) and `.withOptions({ history, clearOnDefault })`. Junk in the URL parses to the default. Full signatures are in [Hooks](/framework/hooks#usequerystatekey-parser). -The same hook on a native host — where there is no URL bar — is plain state shared across pages. A funnel written this way runs unchanged natively; only where the state is kept differs. +The same hook on a native host, where there is no URL bar, is plain state shared across pages. A funnel written this way runs unchanged natively; only where the state is kept differs. ## What the router does on its own @@ -65,9 +65,9 @@ With `checkout` set, `queryState` defaults to on and the route stack is mirrored https://yourapp.superwall.app/funnel?sw_nav=index,goal,interests&goal=habit&interests=reading,speaking ``` -- `router.push` adds a browser history entry; back, replace and dismiss rewrite in place. **The browser's back button is `router.back()`** — including Android's hardware back. +- `router.push` adds a browser history entry; back, replace and dismiss rewrite in place. **The browser's back button is `router.back()`**, including Android's hardware back. - Any URL rebuilds the stack it names, with no animation and one `entry` page view. A route that no longer exists starts the flow over at `index`. -- Writes are coalesced so a text input can't trip Safari's history rate limit, and anything pending is flushed the moment the page is hidden — the instant before a hand-off or the jump to hosted checkout. +- Writes are coalesced so a text input can't trip Safari's history rate limit, and anything pending is flushed the moment the page is hidden, the instant before a hand-off or the jump to hosted checkout. - Foreign params (`utm_*`, attribution) are left untouched, and survive the whole flow. `definePaywall({ queryState: false })` turns it off for a checkout surface; `queryState: true` turns it on for a web surface without checkout. See [Config](/framework/config). @@ -91,7 +91,7 @@ About 2 kB is safe across every app and share sheet, and a question flow of twen - **Short keys, cleared defaults.** `goal`, not `selectedGoalOption`; leave `clearOnDefault` on so untouched answers cost nothing. - **Nothing personal.** URLs end up in referrer and analytics logs. An email or a name belongs in the checkout sheet's own fields, not in the query string. -Keys starting with `sw_`, plus `platform` and `transport`, are reserved — the hook throws on them. +Keys starting with `sw_`, plus `platform` and `transport`, are reserved, the hook throws on them. ## Move like a funnel @@ -106,12 +106,12 @@ export default definePaywall({ }); ``` -Then the plan page prefetches the selected product and `purchase(plan)` opens the sheet — see [Web Checkout](/framework/web-checkout). +Then the plan page prefetches the selected product and `purchase(plan)` opens the sheet, see [Web Checkout](/framework/web-checkout). ## Checklist - `checkout` set in `config.ts`; `transition: "shift"` -- every answer, selection and input is `useQueryState` — no `useState` for anything the person entered +- every answer, selection and input is `useQueryState`, no `useState` for anything the person entered - enum ids, short keys, defaults cleared, nothing personal - later pages guard their reads, so a direct link never crashes - test it: answer two questions, copy the studio's iframe URL into a new tab, and you should land on the same step with the same answers diff --git a/package.json b/package.json index 9b342011..b0ac8a00 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "vite dev", "dev:port": "vite dev --port", "prebuild": "bun run generate:changelog && bun run scripts/copy-docs-images.cjs && bun run generate:og", - "build": "NODE_OPTIONS=--max-old-space-size=5120 vite build && bun run scripts/generate-static-cache.ts && bun run scripts/generate-search-index.ts", + "build": "NODE_OPTIONS=--max-old-space-size=8192 vite build && bun run scripts/generate-static-cache.ts && bun run scripts/generate-search-index.ts", "build:cf": "bun run build", "build:cf:staging": "CLOUDFLARE_ENV=staging bun run build", "sync:mixedbread": "mxbai vs sync $MIXEDBREAD_STORE_ID './content/docs' --ci",