@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,312 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
3
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
4
+ import type { PaymentsClientOptions } from "@pithy-sh/payments/src/client/api";
5
+ import { useCheckout, usePaddleCheckout, usePricePreview } from "@pithy-sh/payments/src/client/hooks";
6
+ import { type PaddleOptions, type PaddleSetup, priceSummary } from "@pithy-sh/payments/src/client/paddle";
7
+ import {
8
+ type PriceVisitor,
9
+ priceQueryFor,
10
+ quoteIsEstimated,
11
+ resolvePriceLocation,
12
+ } from "@pithy-sh/payments/src/pricing/location";
13
+ import type { ReactNode } from "react";
14
+ // `CHECKOUT_FRAME` is imported rather than declared here: the two screens that sell share one class, and
15
+ // `.pithy-checkout` is a hook you are meant to style. Two copies is one styled checkout and one bare one.
16
+ import { CHECKOUT_FRAME, failureText, paddleSetup, paymentsClient, usePriceVisitor } from "../../payments";
17
+ import { paymentsConfig } from "../../pithy-config";
18
+ import { Link, useOptionalScreenPath, useScreenPath, useSignedIn } from "../../router";
19
+ import "../../pithy-screens.css";
20
+
21
+ export const path = "/pricing";
22
+
23
+ // No session. A pricing page is the one screen a stranger has to be able to read, and asking Paddle what
24
+ // this visitor pays needs nothing but the publishable token.
25
+ //
26
+ // Buying is the other half, and it does need an account — the server's checkout route is `requireAuth()`.
27
+ // So this screen draws the visitor it has: a stranger is offered the way in, by name, before the click.
28
+ // Leaving that to the guard would sell someone a price and then meet them with a wall.
29
+
30
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
31
+ //
32
+ // It renders and styles. Every figure on it comes from Paddle, for this visitor — there is no price
33
+ // string in this file, and there must never be one. A hardcoded number is wrong in every country whose
34
+ // tax convention differs from the one it was written in, and it is wrong silently.
35
+
36
+ /** The products this project sells through Paddle, with the price id each is sold at. */
37
+ const PADDLE_PRODUCTS =
38
+ paymentsConfig.enabled && paymentsConfig.rails.paddle
39
+ ? paymentsConfig.products.flatMap((product) =>
40
+ product.skus.paddle === null ? [] : [{ ...product, priceId: product.skus.paddle }],
41
+ )
42
+ : [];
43
+
44
+ /**
45
+ * This screen's English, baked in — the only catalog that survives being copied into your repository.
46
+ *
47
+ * **No figure is in here, and none ever may be.** Every price this screen renders comes from Paddle,
48
+ * for this visitor, in their own currency and under their own tax convention; a number written into a
49
+ * message would be wrong in every country whose convention differs from the one it was typed in, and
50
+ * wrong silently. `templates.test.ts` sweeps this file for one.
51
+ *
52
+ * `{interval}` is Paddle's word for the billing period — `month`, `year` — and arrives from their API
53
+ * rather than from a catalog, so a translated `every` message still names the period in English. Say
54
+ * the period yourself in `messages` if that matters to you; the kit will not invent a vocabulary for
55
+ * somebody else's API.
56
+ */
57
+ const EN = {
58
+ "payments/pricing.title": "What it costs.",
59
+ "payments/pricing.body": "Prices are for where you are. Tax is Paddle's to calculate, not ours.",
60
+ "payments/pricing.anonymous": "Anyone can read a price. Buying needs an account.",
61
+ "payments/pricing.loading": "Getting your price.",
62
+ "payments/pricing.estimated": "Estimated.",
63
+ "payments/pricing.unavailable": "We couldn't get a price. You'll see it at checkout.",
64
+ "payments/pricing.sign_in": "Sign in to buy {product}",
65
+ "payments/pricing.buy": "Buy {product}",
66
+ "payments/pricing.holdings": "What do I already have?",
67
+ "payments/pricing.every.one": "a {interval}",
68
+ "payments/pricing.every.other": "every {count} {interval}s",
69
+ "payments/pricing.empty.title": "Nothing priced here.",
70
+ "payments/pricing.empty.body": "This screen prices what you sell through Paddle. Add a",
71
+ "payments/pricing.empty.body_end": "block to a product in",
72
+ } satisfies MessageCatalog;
73
+
74
+ /**
75
+ * How often a price bills, in words. Null for a one-off, which needs no suffix.
76
+ *
77
+ * Through `plural` rather than a comparison against `1`, because the frequency is a count and a count
78
+ * is what a second locale asks a different question about: English has two forms here, Russian three,
79
+ * and `frequency === 1 ? … : …` has none of them.
80
+ */
81
+ function every(t: Translator, cycle: { interval: string; frequency: number } | null): string | null {
82
+ if (cycle === null) return null;
83
+ return t.plural("payments/pricing.every", cycle.frequency, { interval: cycle.interval });
84
+ }
85
+
86
+ /** One product, as this screen needs it: what to call it, and which price to quote. */
87
+ export interface PricedProduct {
88
+ /** The catalogue id, which `start` buys by. */
89
+ readonly id: string;
90
+ /** The product's name, as the catalogue has it. */
91
+ readonly name: string;
92
+ /** The Paddle price it is sold at — `pri_…`. */
93
+ readonly priceId: string;
94
+ }
95
+
96
+ export interface PricingScreenProps {
97
+ /**
98
+ * The translator this screen renders through.
99
+ *
100
+ * A prop for the reason `visitor` and `paddle` are: what a screen says in a second language is a
101
+ * *rendered* fact no assertion about source text can reach. Absent, the screen reads the provider a
102
+ * `TranslatorProvider` mounted, and with no provider it reads {@link EN}.
103
+ */
104
+ readonly t?: Translator;
105
+ /** What this project sells through Paddle. Empty is a real state, and it has its own screen. */
106
+ readonly products: readonly PricedProduct[];
107
+ /** What Paddle.js starts with, or null when the rail is off. */
108
+ readonly setup: PaddleSetup | null;
109
+ /**
110
+ * Whether there is a session — null while that is still being read.
111
+ *
112
+ * A prop rather than a hook call inside, for the reason `SubscriptionScreen` takes its rails: which
113
+ * control a visitor is offered is a *rendered* fact, and the anonymous one is the case nobody sees
114
+ * while developing signed in.
115
+ */
116
+ readonly signedIn: boolean | null;
117
+ /**
118
+ * Where "sign in to buy" points — the path the sign-in screen declares, read through the role it
119
+ * claims. A prop for the same reason the rest of this screen's world is: nothing here reads a
120
+ * config a Vite build has to produce. Never a literal `/sign-in`, which survives a rename and lands
121
+ * a stranger on the not-found screen (#393).
122
+ *
123
+ * `null` in a payments-only project, where there is no sign-in screen. Nothing sends an anonymous
124
+ * visitor anywhere then — `useSignedIn` answers "signed in" with no auth composed — so the branch
125
+ * this feeds is unreachable there, and a link is not drawn to a screen that does not exist.
126
+ */
127
+ readonly signInPath: string | null;
128
+ /** Where "what do I already have?" points — the subscription screen's declared path, same rule. */
129
+ readonly subscriptionPath: string;
130
+ /**
131
+ * What is known about where this visitor is charged from, or null when nothing is.
132
+ *
133
+ * Null is a real answer and the common one: a stranger reading a marketing page has no billing address
134
+ * anywhere, and Paddle resolving the country from their IP is the best available. What null must never
135
+ * be is a *silent* answer — `resolvePriceLocation` turns it into a location that says it is provisional,
136
+ * and the figure is labelled from that.
137
+ */
138
+ readonly visitor: PriceVisitor | null;
139
+ /** Where the payments routes are, and the fetch to reach them with. Injected so a test never navigates. */
140
+ readonly client?: PaymentsClientOptions;
141
+ /** How Paddle.js is loaded. Injected so a test never reaches Paddle's CDN. */
142
+ readonly paddle?: PaddleOptions;
143
+ }
144
+
145
+ /**
146
+ * The pricing screen, taking its catalogue and its visitor rather than reading them.
147
+ *
148
+ * Two things here are only true of what is *rendered*, which is why this is a component with props and
149
+ * not a file that reads its config: an estimated quote has to look estimated, and an anonymous visitor
150
+ * has to be offered a way in rather than a button that fails.
151
+ */
152
+ export function PricingScreen({
153
+ products,
154
+ setup,
155
+ signedIn,
156
+ signInPath,
157
+ subscriptionPath,
158
+ visitor,
159
+ client,
160
+ paddle,
161
+ t: given,
162
+ }: PricingScreenProps): ReactNode {
163
+ // Called unconditionally, and chosen from afterwards: `given ?? useTranslator(EN)` would skip the hook
164
+ // whenever the prop is passed, which is a hook count that changes between renders.
165
+ const baked = useTranslator(EN);
166
+ const t = given ?? baked;
167
+ /**
168
+ * One quote per product, asked for in one round trip.
169
+ *
170
+ * Rebuilt every render, which costs nothing: the hook depends on `priceQueryKey`, so what re-quotes is
171
+ * the request changing and never the array's identity.
172
+ */
173
+ const items = products.map((product) => ({ priceId: product.priceId, quantity: 1 }));
174
+ /**
175
+ * Where this visitor is priced from, chosen explicitly rather than defaulted into.
176
+ *
177
+ * The whole of the choice lives in the package — a screen scaffolded into an adopter's repo a year ago
178
+ * must not be the thing that decides which of three answers is authoritative. What this file does is
179
+ * hand over what it knows and render what comes back.
180
+ */
181
+ const location = resolvePriceLocation(visitor);
182
+ // Null when there is nothing to quote. A preview for zero items is a request Paddle refuses, and
183
+ // refusing it here costs a round trip and an error message on a screen whose honest state is empty.
184
+ //
185
+ // The query re-issues when the location does. A signed-in customer's `ctm_…` arrives one round trip
186
+ // after the page paints, so the first figure is the IP estimate and the second is the charge — a price
187
+ // that changes once the address is known, which is what every checkout on the web does and what the
188
+ // "Estimated." label is there to have promised in advance.
189
+ const quoted = usePricePreview(items.length > 0 ? setup : null, priceQueryFor(items, location), paddle);
190
+ const checkout = useCheckout(client);
191
+ // The checkout opens over this page or inside it — it never navigates away, which is the whole reason
192
+ // this rail has a pricing screen with a buy button on it rather than a link to somebody else's page.
193
+ const opened = usePaddleCheckout(checkout.handoff, { ...paddle, frameTarget: CHECKOUT_FRAME });
194
+
195
+ if (products.length === 0) {
196
+ return (
197
+ <main className="screen">
198
+ <h1>{t.t("payments/pricing.empty.title")}</h1>
199
+ <p className="muted">
200
+ {t.t("payments/pricing.empty.body")} <code>paddle</code> {t.t("payments/pricing.empty.body_end")}{" "}
201
+ <code>pithy.config.ts</code>.
202
+ </p>
203
+ </main>
204
+ );
205
+ }
206
+
207
+ return (
208
+ <main className="screen">
209
+ <h1>{t.t("payments/pricing.title")}</h1>
210
+ <p className="muted">{t.t("payments/pricing.body")}</p>
211
+
212
+ {/* Said once, at the top, and said before the click rather than after it. A purchase attaches to an
213
+ account, so there has to be one; the visitor who has none learns that from a sentence and a
214
+ named link, not from a redirect that lost what they were doing. */}
215
+ {signedIn === false && <p className="muted">{t.t("payments/pricing.anonymous")}</p>}
216
+
217
+ {/* A failed quote shows no price at all. Falling back to a figure written here would be the exact
218
+ defect this screen exists to remove — and a wrong price is worse than a missing one, because a
219
+ buyer only finds out at the card form. The button still works: checkout is Paddle's own, and it
220
+ quotes again on its own page. */}
221
+ {quoted.failure && <p className="muted">{failureText(t, quoted.failure)}</p>}
222
+ {checkout.failure && <p className="muted">{failureText(t, checkout.failure)}</p>}
223
+ {opened.failure && <p className="muted">{failureText(t, opened.failure)}</p>}
224
+
225
+ <div className="stack">
226
+ {products.map((product) => {
227
+ const line = quoted.preview?.lines.find((quote) => quote.priceId === product.priceId) ?? null;
228
+ const summary = line && quoted.preview ? priceSummary(quoted.preview, line) : null;
229
+ const cycle = line ? every(t, line.billingCycle) : null;
230
+ return (
231
+ <div key={product.id}>
232
+ <p>
233
+ <strong>{product.name}</strong>
234
+ </p>
235
+ {/* Three states, and the middle one is the one that is easy to skip. A blank space where a
236
+ price goes is worse than a beat of waiting, and a beat of waiting is far better than a
237
+ number that corrects itself in front of the buyer. */}
238
+ {quoted.loading ? (
239
+ <p className="muted">{t.t("payments/pricing.loading")}</p>
240
+ ) : summary ? (
241
+ <p>
242
+ <strong>{summary.headline}</strong>
243
+ {cycle && <span className="muted"> {cycle}</span>}
244
+ {/* The honesty half of the quote, and the half that used to be dropped. Two things make
245
+ a figure an estimate and either is enough: the tax is not fully resolved — US tax
246
+ lives below the country and Paddle answers a country-only request with 0% — or the
247
+ location itself is a guess from an IP rather than the address the card is charged
248
+ from. Show what you know, label it, recalculate at the address: an estimate that
249
+ resolves at checkout is correct behaviour, and an estimate rendered as though it
250
+ were final is not. */}
251
+ {quoteIsEstimated(location, summary.estimated) && (
252
+ <span className="muted"> {t.t("payments/pricing.estimated")}</span>
253
+ )}
254
+ {summary.note && <span className="muted"> {summary.note}</span>}
255
+ </p>
256
+ ) : (
257
+ <p className="muted">{t.t("payments/pricing.unavailable")}</p>
258
+ )}
259
+ {signedIn === false ? (
260
+ signInPath && <Link to={signInPath}>{t.t("payments/pricing.sign_in", { product: product.name })}</Link>
261
+ ) : (
262
+ <button
263
+ type="button"
264
+ // Disabled while the session is still being read: a click that lands before the answer
265
+ // would take the guarded path anyway, which is the wall this screen exists to replace.
266
+ disabled={signedIn === null || checkout.starting || opened.opening}
267
+ onClick={() => void checkout.start(product.id)}
268
+ >
269
+ {t.t("payments/pricing.buy", { product: product.name })}
270
+ </button>
271
+ )}
272
+ </div>
273
+ );
274
+ })}
275
+ </div>
276
+
277
+ {/* Rendered from the handoff, and rendered before the checkout opens: Paddle finds this element by
278
+ class name at the moment it opens, so the render revealing it has to commit first. That ordering
279
+ is `usePaddleCheckout`'s job — get it wrong and Paddle throws out of your click handler. */}
280
+ {opened.inline && <div className={CHECKOUT_FRAME} />}
281
+
282
+ <div className="stack">
283
+ <Link className="muted" to={subscriptionPath}>
284
+ {t.t("payments/pricing.holdings")}
285
+ </Link>
286
+ </div>
287
+ </main>
288
+ );
289
+ }
290
+
291
+ export default function Pricing(): ReactNode {
292
+ const signedIn = useSignedIn();
293
+ // The two screens this one points at, each named by the job it does rather than by a path copied
294
+ // into this file (#393).
295
+ const signInPath = useOptionalScreenPath("sign-in");
296
+ const subscriptionPath = useScreenPath("subscription");
297
+ // Asked only once there is a session to ask about. A stranger's page makes no round trip for this —
298
+ // there is nothing on file to fetch, and a marketing page should not wait on a request whose answer is
299
+ // known in advance to be "nobody".
300
+ const visitor = usePriceVisitor(signedIn === true, paymentsClient);
301
+ return (
302
+ <PricingScreen
303
+ products={PADDLE_PRODUCTS}
304
+ setup={paddleSetup}
305
+ signedIn={signedIn}
306
+ signInPath={signInPath}
307
+ subscriptionPath={subscriptionPath}
308
+ visitor={visitor}
309
+ client={paymentsClient}
310
+ />
311
+ );
312
+ }
@@ -0,0 +1,116 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { expect, test, vi } from "vitest";
6
+
7
+ /**
8
+ * **The magic link comes back to the callback screen's declared path, never to a copy of it.**
9
+ *
10
+ * A magic link is issued with a `callbackURL`, and the screen that answers it is
11
+ * `src/routes/pithy/callback.tsx`. Those used to be two strings: a `` `${origin}/callback` `` template
12
+ * here and an `export const path = "/callback"` there, with nothing comparing them. Renaming the
13
+ * screen's path — an ordinary rebrand — typechecked, linted, built, and broke every sign-in, because
14
+ * the link then landed on the not-found screen (#393).
15
+ *
16
+ * It is the one flow you cannot test by being signed in, so nothing about your day would tell you.
17
+ *
18
+ * ## What this proves, and how it goes red
19
+ *
20
+ * `./callback` is stubbed with a path that is **deliberately not** the real one. A screen that wrote
21
+ * the callback path as a literal would still send `/callback`, and this goes red; only one that reads
22
+ * the screen's own export can pass. The expected value is invented here and reachable from nowhere
23
+ * else — asserting `"/callback"` would have passed against the exact drift this catches.
24
+ *
25
+ * `@pithy-sh/auth`'s client API is stubbed to record rather than send: what is under test is the
26
+ * string this screen builds, not the request the kit makes with it. `src/pithy-config.tsx` is stubbed
27
+ * for the reason `src/turnstile.test.tsx` gives — it reads `virtual:pithy/*` modules that only a Vite
28
+ * build serves, and stubbing it keeps this gate running under the plain `vitest run` you already have.
29
+ */
30
+
31
+ /** The path the stubbed callback screen declares. Not the real one, on purpose. */
32
+ const CANARY_CALLBACK = "/pithy-gate-canary-not-the-callback-path";
33
+
34
+ /** The origin the screen is told it is on, so the assertion is about the whole URL. */
35
+ const ORIGIN = "https://gate.example";
36
+
37
+ vi.mock("./callback", () => ({ path: CANARY_CALLBACK, default: () => null }));
38
+
39
+ vi.mock("../../pithy-config", () => ({
40
+ authConfig: { basePath: "/auth", providers: {}, signUpEnabled: true },
41
+ turnstileConfig: { enabled: false, sitekey: "", mode: "visible", action: "", token: { field: "", header: null } },
42
+ }));
43
+
44
+ /** Every `callbackURL` the screen handed the kit, in order. */
45
+ const issued: string[] = [];
46
+
47
+ vi.mock("@pithy-sh/auth/src/client/api", () => ({
48
+ sendMagicLink: async (body: { callbackURL: string }) => {
49
+ issued.push(body.callbackURL);
50
+ },
51
+ startSocialSignIn: async (body: { callbackURL: string }) => {
52
+ issued.push(body.callbackURL);
53
+ return { kind: "silent" as const };
54
+ },
55
+ }));
56
+
57
+ // React refuses to run `act` unless the environment says it is a test one.
58
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
59
+
60
+ /** A projection with one provider on, so both ways out of this screen are exercised. */
61
+ const AUTH = { basePath: "/auth", providers: { google: true }, signUpEnabled: true };
62
+
63
+ test("the link comes back to whatever path callback.tsx declares", async () => {
64
+ // The canary must not have drifted onto the real path: `/callback` would pass against the bug.
65
+ expect(CANARY_CALLBACK).not.toBe("/callback");
66
+
67
+ // Imported inside the case so the stubs are in place before this module's scope reads them.
68
+ const { SignInScreen } = await import("./sign-in");
69
+
70
+ const host = document.createElement("div");
71
+ document.body.appendChild(host);
72
+ await act(async () => {
73
+ createRoot(host).render(<SignInScreen auth={AUTH} origin={ORIGIN} />);
74
+ });
75
+
76
+ const field = host.querySelector<HTMLInputElement>("input[type=email]");
77
+ expect(field, "the sign-in screen rendered no email field").not.toBeNull();
78
+ await act(async () => {
79
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
80
+ setter?.call(field, "someone@example.com");
81
+ field?.dispatchEvent(new Event("input", { bubbles: true }));
82
+ });
83
+ await act(async () => {
84
+ host.querySelector("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
85
+ });
86
+
87
+ expect(issued, "the screen sent no magic link").toHaveLength(1);
88
+ expect(issued[0]).toBe(`${ORIGIN}${CANARY_CALLBACK}`);
89
+ });
90
+
91
+ test("this screen still claims the role the router's guard looks up", async () => {
92
+ // Not two copies of a path: a job name, and the screen that says it does that job. Delete the claim
93
+ // and every signed-out visitor is sent nowhere — which, again, is a thing you cannot see signed in.
94
+ const screen = await import("./sign-in");
95
+ expect(screen.role).toBe("sign-in");
96
+ });
97
+
98
+ test("a provider comes back to the same place, so the two ways in cannot drift apart", async () => {
99
+ expect(CANARY_CALLBACK).not.toBe("/callback");
100
+ issued.length = 0;
101
+
102
+ const { SignInScreen } = await import("./sign-in");
103
+ const host = document.createElement("div");
104
+ document.body.appendChild(host);
105
+ await act(async () => {
106
+ createRoot(host).render(<SignInScreen auth={AUTH} origin={ORIGIN} />);
107
+ });
108
+
109
+ const provider = host.querySelector("button[aria-label]");
110
+ expect(provider, "no provider button rendered").not.toBeNull();
111
+ await act(async () => {
112
+ provider?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
113
+ });
114
+
115
+ expect(issued).toEqual([`${ORIGIN}${CANARY_CALLBACK}`]);
116
+ });