@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.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/src/templates.ts +147 -0
- package/src/testing/virtualAuth.ts +12 -0
- package/src/testing/virtualI18n.ts +16 -0
- package/src/testing/virtualPayments.ts +5 -0
- package/src/testing/virtualTurnstile.ts +5 -0
- package/templates/client-env.d.ts +299 -0
- package/templates/index.html +14 -0
- package/templates/src/client.test.tsx +98 -0
- package/templates/src/client.tsx +51 -0
- package/templates/src/payments.tsx +147 -0
- package/templates/src/pithy-config.tsx +93 -0
- package/templates/src/pithy-locale.test.tsx +140 -0
- package/templates/src/pithy-locale.tsx +134 -0
- package/templates/src/pithy-screens.css +379 -0
- package/templates/src/router.test.tsx +63 -0
- package/templates/src/router.tsx +618 -0
- package/templates/src/routes/app/home.bare.tsx +96 -0
- package/templates/src/routes/app/home.tsx +41 -0
- package/templates/src/routes/pithy/callback.tsx +42 -0
- package/templates/src/routes/pithy/otp.tsx +127 -0
- package/templates/src/routes/pithy/paywall.tsx +160 -0
- package/templates/src/routes/pithy/pricing.tsx +312 -0
- package/templates/src/routes/pithy/sign-in.test.tsx +116 -0
- package/templates/src/routes/pithy/sign-in.tsx +470 -0
- package/templates/src/routes/pithy/subscription.tsx +183 -0
- package/templates/src/session.tsx +78 -0
- package/templates/src/styles.css +53 -0
- package/templates/src/turnstile.test.tsx +119 -0
- package/templates/src/turnstile.tsx +105 -0
- package/templates/tsconfig.client.json +28 -0
- package/templates/tsconfig.node.json +22 -0
- 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
|
+
}
|