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..08502592 --- /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. + +```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. 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..f75b5eb9 --- /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." +--- + +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 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. + +## 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.925; /* how much the page behind dims (the default) */ +} +``` + +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.",