@pithy-sh/ui-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/src/templates.ts +147 -0
  4. package/src/testing/virtualAuth.ts +12 -0
  5. package/src/testing/virtualI18n.ts +16 -0
  6. package/src/testing/virtualPayments.ts +5 -0
  7. package/src/testing/virtualTurnstile.ts +5 -0
  8. package/templates/client-env.d.ts +299 -0
  9. package/templates/index.html +14 -0
  10. package/templates/src/client.test.tsx +98 -0
  11. package/templates/src/client.tsx +51 -0
  12. package/templates/src/payments.tsx +147 -0
  13. package/templates/src/pithy-config.tsx +93 -0
  14. package/templates/src/pithy-locale.test.tsx +140 -0
  15. package/templates/src/pithy-locale.tsx +134 -0
  16. package/templates/src/pithy-screens.css +379 -0
  17. package/templates/src/router.test.tsx +63 -0
  18. package/templates/src/router.tsx +618 -0
  19. package/templates/src/routes/app/home.bare.tsx +96 -0
  20. package/templates/src/routes/app/home.tsx +41 -0
  21. package/templates/src/routes/pithy/callback.tsx +42 -0
  22. package/templates/src/routes/pithy/otp.tsx +127 -0
  23. package/templates/src/routes/pithy/paywall.tsx +160 -0
  24. package/templates/src/routes/pithy/pricing.tsx +312 -0
  25. package/templates/src/routes/pithy/sign-in.test.tsx +116 -0
  26. package/templates/src/routes/pithy/sign-in.tsx +470 -0
  27. package/templates/src/routes/pithy/subscription.tsx +183 -0
  28. package/templates/src/session.tsx +78 -0
  29. package/templates/src/styles.css +53 -0
  30. package/templates/src/turnstile.test.tsx +119 -0
  31. package/templates/src/turnstile.tsx +105 -0
  32. package/templates/tsconfig.client.json +28 -0
  33. package/templates/tsconfig.node.json +22 -0
  34. package/templates/vite.config.ts +45 -0
@@ -0,0 +1,96 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
3
+ import { HEALTH_PATH } from "@pithy-sh/core/src/worker/health";
4
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
5
+ import { useEffect, useState } from "react";
6
+
7
+ // Your screen. Pithy wrote this file once and never will again — everything under src/routes/app/ is
8
+ // yours. Add a screen by dropping a file in beside it with its own `path` export.
9
+ export const path = "/";
10
+
11
+ /** What the health route answers. Every Pithy worker serves it. */
12
+ interface Health {
13
+ status: string;
14
+ }
15
+
16
+ function isHealth(value: unknown): value is Health {
17
+ return typeof value === "object" && value !== null && typeof (value as { status?: unknown }).status === "string";
18
+ }
19
+
20
+ /**
21
+ * This screen's English, baked in. Yours, like the rest of the file.
22
+ *
23
+ * The three statuses are keys rather than the strings themselves, because two of them are this screen's
24
+ * own words and not the worker's: `checking` is what it says before the answer arrives, and `unknown`
25
+ * and `unreachable` are what it says when there is no answer to render. What the worker actually
26
+ * returns — `ok` — is its value and is shown verbatim.
27
+ */
28
+ const EN = {
29
+ "app/home.bare.title": "It runs.",
30
+ "app/home.bare.body": "The worker says: {status}.",
31
+ "app/home.bare.checking": "checking",
32
+ "app/home.bare.unknown": "unknown",
33
+ "app/home.bare.unreachable": "unreachable",
34
+ } satisfies MessageCatalog;
35
+
36
+ /**
37
+ * What the health check came back with: the worker's own word, or one of this screen's two faults.
38
+ *
39
+ * A union rather than a rendered string, because only one of the three is translatable. `ok` is the
40
+ * worker's value and is shown verbatim in every language; `unknown` and `unreachable` are this
41
+ * screen's own words and come from the catalog. Storing the *rendered* sentence would mean the effect
42
+ * had to hold a translator, which is what made it re-run on every language change.
43
+ */
44
+ type Said = { reported: string } | { fault: "unknown" | "unreachable" } | null;
45
+
46
+ /** The word to show: the worker's own, or this screen's for a fault, or its word for still waiting. */
47
+ function said(t: Translator, health: Said): string {
48
+ if (health === null) return t.t("app/home.bare.checking");
49
+ return "reported" in health ? health.reported : t.t(`app/home.bare.${health.fault}`);
50
+ }
51
+
52
+ export default function Home() {
53
+ const t = useTranslator(EN);
54
+ const [status, setStatus] = useState<Said>(null);
55
+
56
+ useEffect(() => {
57
+ let live = true;
58
+ // Same origin. The SPA and the worker are one deploy on one origin, so paths are relative and
59
+ // there is no CORS config and no API origin variable — in dev or in production.
60
+ //
61
+ // **No cookie mode, and that is the whole of the request's security story.** The health route is
62
+ // public: it reads nothing about you, so there is no session to send it, and this is the one screen
63
+ // a project with no auth composed still gets. Every request that *does* carry a session goes through
64
+ // `@pithy-sh/auth/src/client/api` instead — which is not importable from here, and does not need to
65
+ // be, because a request with no ambient credential on it is not the rule that primitive exists to
66
+ // own (#370).
67
+ //
68
+ // **`HEALTH_PATH`, not a string.** The worker mounts this route from that same constant. A copy
69
+ // here would go stale the day it moves and render "The worker says: unknown." — a 200, no error,
70
+ // nothing in a log (#400). It is yours to change; changing it to a literal is the way to lose that.
71
+ fetch(HEALTH_PATH)
72
+ .then((response) => response.json())
73
+ .then((body: unknown) => {
74
+ if (live) setStatus(isHealth(body) ? { reported: body.status } : { fault: "unknown" });
75
+ })
76
+ .catch(() => {
77
+ if (live) setStatus({ fault: "unreachable" });
78
+ });
79
+ return () => {
80
+ live = false;
81
+ };
82
+ // **No dependencies, because this asks the worker once per mount and the words are not part of
83
+ // asking.** `t` was listed here, on the reasoning that it is stable for the life of the screen. It
84
+ // is not: `useTranslator` memoizes on the provider value, and the provider mounts once the locale's
85
+ // catalog has loaded — so `t` changes identity on that transition and the health check fired twice
86
+ // on every mount, and again on every language change. The raw state is stored and translated at
87
+ // render instead, which is where a language change belongs anyway.
88
+ }, []);
89
+
90
+ return (
91
+ <main className="screen">
92
+ <h1>{t.t("app/home.bare.title")}</h1>
93
+ <p className="muted">{t.t("app/home.bare.body", { status: said(t, status) })}</p>
94
+ </main>
95
+ );
96
+ }
@@ -0,0 +1,41 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import { signOut, useSession } from "../../session";
4
+
5
+ // Your screen. Pithy wrote this file once and never will again — everything under src/routes/app/ is
6
+ // yours. Add a screen by dropping a file in beside it with its own `path` export.
7
+ export const path = "/";
8
+
9
+ // Signed-out visitors are sent to /sign-in. Delete this line to make the screen public.
10
+ export const session = "required";
11
+
12
+ /**
13
+ * This screen's English, baked in. Yours, like the rest of the file.
14
+ *
15
+ * Keyed under `app/` because this screen is yours rather than a capability's — a capability may only
16
+ * declare keys under its own name, and none of them is called `app`. Add your own keys here and they
17
+ * translate through the same layers the kit's do.
18
+ */
19
+ const EN = {
20
+ "app/home.title": "You're in.",
21
+ "app/home.signed_in_as": "Signed in as {email}.",
22
+ "app/home.someone": "someone",
23
+ "app/home.sign_out": "Sign out",
24
+ } satisfies MessageCatalog;
25
+
26
+ export default function Home() {
27
+ const t = useTranslator(EN);
28
+ const { session: current } = useSession();
29
+
30
+ return (
31
+ <main className="screen">
32
+ <h1>{t.t("app/home.title")}</h1>
33
+ <p className="muted">{t.t("app/home.signed_in_as", { email: current?.user.email ?? t.t("app/home.someone") })}</p>
34
+ <div className="stack">
35
+ <button type="button" className="secondary" onClick={() => void signOut()}>
36
+ {t.t("app/home.sign_out")}
37
+ </button>
38
+ </div>
39
+ </main>
40
+ );
41
+ }
@@ -0,0 +1,42 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import { useEffect } from "react";
4
+ import { navigate, routeTable, screenPath } from "../../router";
5
+ import { getSession } from "../../session";
6
+ import "../../pithy-screens.css";
7
+
8
+ export const path = "/callback";
9
+
10
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
11
+ //
12
+ // `sign-in.tsx` builds the magic link's `callbackURL` from the `path` above, so renaming it moves both
13
+ // ends at once. That is the contract #393 exists for — the round trip is the one flow nobody already
14
+ // signed in can test, so it must not be two strings that only happen to agree.
15
+
16
+ /** This screen's English, baked in — the only catalog that survives being copied into your repository. */
17
+ const EN = {
18
+ "auth/callback.title": "Signing you in.",
19
+ "auth/callback.body": "One moment.",
20
+ } satisfies MessageCatalog;
21
+
22
+ export default function Callback() {
23
+ const t = useTranslator(EN);
24
+
25
+ useEffect(() => {
26
+ // The session cookie is already set by the time we get here — the server did the verifying.
27
+ // This screen just asks who you are and moves on.
28
+ //
29
+ // Where "away" is comes from the route table, never a literal: the sign-in screen declares its own
30
+ // path and claims the role, and this reads whatever it declared.
31
+ void Promise.all([getSession(), routeTable()]).then(([current, table]) =>
32
+ navigate(current ? "/" : screenPath(table, "sign-in")),
33
+ );
34
+ }, []);
35
+
36
+ return (
37
+ <main className="screen">
38
+ <h1>{t.t("auth/callback.title")}</h1>
39
+ <p className="muted">{t.t("auth/callback.body")}</p>
40
+ </main>
41
+ );
42
+ }
@@ -0,0 +1,127 @@
1
+ import { sendOtp, signInWithOtp } from "@pithy-sh/auth/src/client/api";
2
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
3
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
4
+ import { useCallback, useRef, useState } from "react";
5
+ import { authConfig } from "../../pithy-config";
6
+ import { navigate, useSearchParam } from "../../router";
7
+ import { Turnstile, turnstilePending, turnstileRequest } from "../../turnstile";
8
+ import "../../pithy-screens.css";
9
+
10
+ export const path = "/otp";
11
+
12
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
13
+ //
14
+ // **Nothing sends anyone here by default.** The sign-in screen offers one way in — the magic link —
15
+ // because two passwordless paths on one screen is two things to explain, two surfaces to rate-limit,
16
+ // and two inboxes' worth of mail for one intent. This screen stays because the server route behind it
17
+ // does: if you would rather have a code, send one from your own screen and route here with
18
+ // `navigate(`/otp?email=${encodeURIComponent(email)}`)`.
19
+ //
20
+ // **The email stays a query parameter, now that `/otp/:email` is expressible.** A path parameter
21
+ // identifies the thing a URL points at, and this URL points at the code entry screen — the address is
22
+ // a prefill, not the resource. `/otp` with no email is a valid screen and renders "your inbox", which
23
+ // a path segment cannot say without an optional-segment rule the router deliberately does not have.
24
+ // Nothing mails a link here either, so the argument for identifiers in the path — that a link is the
25
+ // way in — does not apply. And an address in a path is PII in every access log and referrer along the
26
+ // way: the same argument that favours the path for a token, pointing the other way for an email.
27
+
28
+ /**
29
+ * This screen's English, baked in — the only catalog that survives being copied into your repository.
30
+ *
31
+ * The digit count is a real count, so it goes through `plural` rather than being concatenated onto a
32
+ * noun: `otpLength` is configurable, and a locale with more than two plural forms has no way to say
33
+ * "digits" correctly from a single string.
34
+ */
35
+ const EN = {
36
+ "auth/otp.title": "Enter the code.",
37
+ "auth/otp.sent.one": "We sent {count} digit to {email}.",
38
+ "auth/otp.sent.other": "We sent {count} digits to {email}.",
39
+ "auth/otp.inbox": "your inbox",
40
+ "auth/otp.failed": "That code didn't work. Try again, or send a new one.",
41
+ "auth/otp.submit": "Sign in",
42
+ "auth/otp.resend": "Send a new code",
43
+ } satisfies MessageCatalog;
44
+
45
+ export default function Otp() {
46
+ const t = useTranslator(EN);
47
+ const email = useSearchParam("email") ?? "";
48
+ const [digits, setDigits] = useState<string[]>(() => Array.from({ length: authConfig.otpLength }, () => ""));
49
+ const [captcha, setCaptcha] = useState<string | null>(null);
50
+ const [error, setError] = useState(false);
51
+ const [busy, setBusy] = useState(false);
52
+ const inputs = useRef<(HTMLInputElement | null)[]>([]);
53
+ const onToken = useCallback((value: string | null) => setCaptcha(value), []);
54
+
55
+ const code = digits.join("");
56
+
57
+ function setDigit(index: number, value: string): void {
58
+ const digit = value.replace(/\D/g, "").slice(-1);
59
+ setDigits((current) => current.map((existing, position) => (position === index ? digit : existing)));
60
+ if (digit && index + 1 < authConfig.otpLength) inputs.current[index + 1]?.focus();
61
+ }
62
+
63
+ // Where the auth routes are. Both calls below name an intent and nothing about transport: the
64
+ // base-path join, the cookie mode and the failure directions belong to `@pithy-sh/auth`, which can
65
+ // still fix them after this file is yours.
66
+ const client = { basePath: authConfig.basePath };
67
+
68
+ async function verify(): Promise<void> {
69
+ setBusy(true);
70
+ setError(false);
71
+ const result = await signInWithOtp({ email, otp: code }, client);
72
+ setBusy(false);
73
+ if (result.ok) navigate("/");
74
+ else setError(true);
75
+ }
76
+
77
+ async function resend(): Promise<void> {
78
+ // The gate goes on the send route and not on the verify one, which is where `@pithy-sh/auth`
79
+ // stacks the humanity check: a code that was already mailed is not a surface worth challenging.
80
+ await sendOtp({ email, type: "sign-in" }, { ...client, gate: (body) => turnstileRequest(body, captcha) });
81
+ }
82
+
83
+ return (
84
+ <main className="screen">
85
+ <h1>{t.t("auth/otp.title")}</h1>
86
+ <p className="muted">
87
+ {t.plural("auth/otp.sent", authConfig.otpLength, { email: email || t.t("auth/otp.inbox") })}
88
+ </p>
89
+
90
+ <div className="otp">
91
+ {digits.map((digit, index) => (
92
+ <input
93
+ // The inputs are a fixed-length positional row; the position is the identity.
94
+ // biome-ignore lint/suspicious/noArrayIndexKey: position is the identity here
95
+ key={index}
96
+ ref={(element) => {
97
+ inputs.current[index] = element;
98
+ }}
99
+ inputMode="numeric"
100
+ autoComplete="one-time-code"
101
+ maxLength={1}
102
+ value={digit}
103
+ onChange={(event) => setDigit(index, event.target.value)}
104
+ />
105
+ ))}
106
+ </div>
107
+
108
+ {error && <p className="muted">{t.t("auth/otp.failed")}</p>}
109
+
110
+ <div className="stack">
111
+ <button type="button" disabled={busy || code.length < authConfig.otpLength} onClick={() => void verify()}>
112
+ {t.t("auth/otp.submit")}
113
+ </button>
114
+ {/* Resending goes back through the gated send route, so the widget belongs here too. */}
115
+ <Turnstile onToken={onToken} />
116
+ <button
117
+ type="button"
118
+ className="secondary"
119
+ disabled={busy || turnstilePending(captcha)}
120
+ onClick={() => void resend()}
121
+ >
122
+ {t.t("auth/otp.resend")}
123
+ </button>
124
+ </div>
125
+ </main>
126
+ );
127
+ }
@@ -0,0 +1,160 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import {
4
+ PAYMENTS_HOSTED_RAILS,
5
+ type PaymentsHostedRail,
6
+ returnedCheckoutSession,
7
+ } from "@pithy-sh/payments/src/client/api";
8
+ import { useCheckout, usePaddleCheckout, usePurchase } from "@pithy-sh/payments/src/client/hooks";
9
+ import { useEffect, useState } from "react";
10
+ // `CHECKOUT_FRAME` is imported rather than declared here: the two screens that sell share one class, and
11
+ // `.pithy-checkout` is a hook you are meant to style. Two copies is one styled checkout and one bare one.
12
+ import { CHECKOUT_FRAME, failureText, paymentsClient } from "../../payments";
13
+ import { paymentsConfig } from "../../pithy-config";
14
+ import { Link, useScreenPath } from "../../router";
15
+ import "../../pithy-screens.css";
16
+
17
+ export const path = "/paywall";
18
+
19
+ // The screen the router's entitlement guard sends people to. Rename `path` above and the guard follows,
20
+ // because it reads this claim rather than holding a copy of the string (#393).
21
+ export const role = "paywall";
22
+
23
+ // Buying attaches a purchase to an account, so there has to be one.
24
+ export const session = "required";
25
+
26
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
27
+ //
28
+ // It renders and styles; every call it makes belongs to @pithy-sh/payments. That split is the point — this
29
+ // file is written once and never rewritten, and store rules change. A purchase flow copied into here would
30
+ // be one Pithy could not fix for you; one that calls the hooks upgrades with a minor release.
31
+
32
+ /**
33
+ * What a product can do on the web: any enabled hosted rail this product is actually listed on.
34
+ *
35
+ * The list is `PAYMENTS_HOSTED_RAILS`, from the package. It used to be three names written out here,
36
+ * which is the shape #336 was about — correct on the day, one rail short a release later, and frozen
37
+ * the moment this file was copied into a repo. Imported, a rail added to Pithy reaches a paywall
38
+ * scaffolded a year ago. Apple and Google are not on it: those purchases happen inside a store SDK,
39
+ * and a web page can say a product exists and nothing more.
40
+ */
41
+ function purchasable(product: { skus: Record<PaymentsHostedRail, string | null> }): boolean {
42
+ return PAYMENTS_HOSTED_RAILS.some((rail) => paymentsConfig.rails[rail] && product.skus[rail] !== null);
43
+ }
44
+
45
+ /**
46
+ * This screen's English, baked in — the only catalog that survives being copied into your repository.
47
+ *
48
+ * A project composing no `i18n` capability renders exactly these sentences. With it composed they
49
+ * become the last layer: your own catalog first, then the kit's translation, then this.
50
+ *
51
+ * `pithy.config.ts` is not in a message. A file name is not copy, and a translator who rendered it in
52
+ * their own language would name a file that does not exist.
53
+ */
54
+ const EN = {
55
+ "payments/paywall.title": "Go further.",
56
+ "payments/paywall.body": "Pick what you need. You can change your mind later.",
57
+ "payments/paywall.buy": "Buy {product}",
58
+ "payments/paywall.in_app": "Available in the app.",
59
+ "payments/paywall.holdings": "What do I already have?",
60
+ "payments/paywall.empty.title": "Nothing for sale.",
61
+ "payments/paywall.empty.body": "This project has no catalog yet. Add products to",
62
+ "payments/paywall.done.title": "You're set.",
63
+ "payments/paywall.done.body": "Thanks. Your purchase is on your account.",
64
+ "payments/paywall.done.home": "Go home",
65
+ } satisfies MessageCatalog;
66
+
67
+ export default function Paywall() {
68
+ const t = useTranslator(EN);
69
+ // Read before the early returns, because it is a hook — and read at all, rather than written out as
70
+ // `/subscription`, because the subscription screen declares its own path (#393).
71
+ const subscriptionPath = useScreenPath("subscription");
72
+ const checkout = useCheckout(paymentsClient);
73
+ // Paddle's overlay and inline modes never leave this page, so the handoff has to be opened here. Every
74
+ // other hosted rail has already navigated away by the time `start` resolves and this reads null.
75
+ const opened = usePaddleCheckout(checkout.handoff, { frameTarget: CHECKOUT_FRAME });
76
+ const purchase = usePurchase(paymentsClient);
77
+ const [returned] = useState(() => returnedCheckoutSession());
78
+
79
+ // Coming back from hosted Checkout, the success URL carries the session id. Posting it projects the
80
+ // purchase at once, so the entitlement shows now rather than whenever the webhook lands. The webhook is
81
+ // still authoritative and still arrives; dropping this call would only cost the buyer a few seconds.
82
+ //
83
+ // Stripe only, and by construction rather than by a check: no other rail substitutes a session id into
84
+ // the return URL, so `returned` is null coming back from one. A Lemon Squeezy buyer waits for the
85
+ // webhook — that rail has no receipt a client could submit, because its order ids are sequential
86
+ // integers and any authenticated caller could claim an order by counting.
87
+ useEffect(() => {
88
+ if (returned) void purchase.submit("stripe", returned);
89
+ // The session id is read once, into state, so this runs once per return rather than once per render —
90
+ // and `submit` is a stable callback, so listing it costs nothing and keeps the list honest.
91
+ }, [returned, purchase.submit]);
92
+
93
+ if (!paymentsConfig.enabled) {
94
+ return (
95
+ <main className="screen">
96
+ <h1>{t.t("payments/paywall.empty.title")}</h1>
97
+ <p className="muted">
98
+ {t.t("payments/paywall.empty.body")} <code>pithy.config.ts</code>.
99
+ </p>
100
+ </main>
101
+ );
102
+ }
103
+
104
+ if (returned && purchase.purchase) {
105
+ return (
106
+ <main className="screen">
107
+ <h1>{t.t("payments/paywall.done.title")}</h1>
108
+ <p className="muted">{t.t("payments/paywall.done.body")}</p>
109
+ <div className="stack">
110
+ <Link to="/">{t.t("payments/paywall.done.home")}</Link>
111
+ </div>
112
+ </main>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <main className="screen">
118
+ <h1>{t.t("payments/paywall.title")}</h1>
119
+ <p className="muted">{t.t("payments/paywall.body")}</p>
120
+
121
+ {purchase.failure && <p className="muted">{failureText(t, purchase.failure)}</p>}
122
+ {checkout.failure && <p className="muted">{failureText(t, checkout.failure)}</p>}
123
+ {opened.failure && <p className="muted">{failureText(t, opened.failure)}</p>}
124
+
125
+ <div className="stack">
126
+ {paymentsConfig.products.map((product) => (
127
+ <div key={product.id}>
128
+ <p>
129
+ <strong>{product.name}</strong>
130
+ </p>
131
+ {purchasable(product) ? (
132
+ <button
133
+ type="button"
134
+ disabled={checkout.starting || opened.opening}
135
+ onClick={() => void checkout.start(product.id)}
136
+ >
137
+ {t.t("payments/paywall.buy", { product: product.name })}
138
+ </button>
139
+ ) : (
140
+ // Display only. StoreKit and Play Billing need native app code to present a purchase sheet,
141
+ // so a web page can say a product exists and where to get it, and nothing more.
142
+ <p className="muted">{t.t("payments/paywall.in_app")}</p>
143
+ )}
144
+ </div>
145
+ ))}
146
+ </div>
147
+
148
+ {/* Rendered from the handoff rather than from a guess at your config, and rendered *before* the
149
+ checkout opens: Paddle looks this element up by class name at that moment, and throws if the
150
+ render revealing it has not committed. That ordering is `usePaddleCheckout`'s job. */}
151
+ {opened.inline && <div className={CHECKOUT_FRAME} />}
152
+
153
+ <div className="stack">
154
+ <Link className="muted" to={subscriptionPath}>
155
+ {t.t("payments/paywall.holdings")}
156
+ </Link>
157
+ </div>
158
+ </main>
159
+ );
160
+ }