@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,470 @@
1
+ import type { AuthFetch } from "@pithy-sh/auth/src/client/api";
2
+ import { sendMagicLink, startSocialSignIn } from "@pithy-sh/auth/src/client/api";
3
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
4
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
5
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
6
+ import { type FormEvent, type ReactNode, useCallback, useMemo, useState } from "react";
7
+ import { authConfig } from "../../pithy-config";
8
+ import { Turnstile, turnstilePending, turnstileRequest } from "../../turnstile";
9
+ // The magic link's callback URL is built from this, never from a literal of the same shape. The two are
10
+ // one statement for the reason #393 gives: renaming `callback.tsx`'s path is an edit that typechecks,
11
+ // builds, and breaks the one round trip nobody signed in can test.
12
+ import { path as callbackPath } from "./callback";
13
+ import "../../pithy-screens.css";
14
+
15
+ export const path = "/sign-in";
16
+
17
+ // The screen the router's signed-out guard sends people to. Rename `path` above and the guard follows,
18
+ // because it reads this claim rather than holding a copy of the string (#393).
19
+ export const role = "sign-in";
20
+
21
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
22
+ // (Guarding your home screen is one line — see `export const session` in src/routes/app/home.tsx.)
23
+
24
+ /*
25
+ * ─────────────────────────────────────────────────────────────────────────────
26
+ * THE TWO SLOTS. This is the part of the screen that is yours, and it ships empty.
27
+ *
28
+ * A sign-in page is a two-column layout: a panel that says what this product is, and the form. The
29
+ * panel is the half no template can write for you — it is your mark, your sentence, your claims — so
30
+ * it is a slot rather than copy you would have to find and delete. Fill it or leave it: with nothing
31
+ * here the page is one centred column and the layout is still correct.
32
+ *
33
+ * const BRAND = (
34
+ * <>
35
+ * <h2>Your data stays yours.</h2>
36
+ * <p>One sentence about what this is.</p>
37
+ * </>
38
+ * );
39
+ * const MARK = <img src="/logo.svg" alt="Acme" height={28} />;
40
+ *
41
+ * BRAND is the panel. MARK is the small header that appears in the form column at the widths where
42
+ * the panel is not rendered — exactly one of the two is ever on screen, so the page carries your
43
+ * identity at every size without saying your name twice. Both are ordinary JSX; neither is styled by
44
+ * Pithy beyond the box it sits in.
45
+ * ─────────────────────────────────────────────────────────────────────────────
46
+ */
47
+
48
+ /** The panel beside the form. Yours. */
49
+ const BRAND: ReactNode = null;
50
+
51
+ /** The compact mark, for the widths the panel is not on. Yours. */
52
+ const MARK: ReactNode = null;
53
+
54
+ /**
55
+ * This screen's English, baked in.
56
+ *
57
+ * **The catalog that survives being copied.** This file is written into your repository once and
58
+ * belongs to you afterwards, so the English cannot live in a package you might never install: it lives
59
+ * here, and a project that composes no `i18n` capability renders exactly these sentences with no
60
+ * negotiation, no merge and no config.
61
+ *
62
+ * With `i18n` composed it becomes the *last* layer instead — your own catalog first, then the kit's
63
+ * translation, then this. So a key nobody has translated still renders a sentence rather than a key.
64
+ *
65
+ * Edit the words here to change the English. Translate them by adding the same keys under a locale in
66
+ * `i18n({ messages })`; the key is `<capability>/<path>` and it is the join between the two.
67
+ */
68
+ const EN = {
69
+ "auth/sign_in.title": "Welcome.",
70
+ "auth/sign_in.provider.label": "Continue with {provider}",
71
+ "auth/sign_in.provider_unconfigured": "{provider} is not configured here. Use the link instead.",
72
+ "auth/sign_in.provider_silent": "{provider} didn't answer. Use the link instead.",
73
+ "auth/sign_in.divider": "or",
74
+ "auth/sign_in.email.label": "Email",
75
+ "auth/sign_in.submit": "Email me a link",
76
+ "auth/sign_in.signup.prompt": "No account yet?",
77
+ "auth/sign_in.signup.answer": "Signing in creates one.",
78
+ "auth/sign_in.signup.closed": "Existing accounts only.",
79
+ "auth/sign_in.sent.title": "Check your inbox.",
80
+ "auth/sign_in.sent.body": "If that address can sign in, a link is on its way. The link expires shortly.",
81
+ } satisfies MessageCatalog;
82
+
83
+ /** The auth capability's client-safe projection — the half of it this screen reads. */
84
+ export interface AuthProjection {
85
+ /** Where the auth handler mounts, e.g. `/auth`. */
86
+ readonly basePath: string;
87
+ /** Which social providers are switched on in `pithy.config.ts`. Credentials never reach a browser. */
88
+ readonly providers: Readonly<Record<string, boolean>>;
89
+ /** Whether signing in may provision a new account. Drives one sentence of copy. */
90
+ readonly signUpEnabled: boolean;
91
+ }
92
+
93
+ /** The humanity check, injected so this module never has to know whether one is composed. */
94
+ export interface HumanityCheck {
95
+ /** The widget itself. Rendered inside the form, above the submit. */
96
+ readonly widget: ReactNode;
97
+ /** True while the check still owes a token, so the gated submit stays disabled. */
98
+ readonly pending: boolean;
99
+ /** Puts the token where the middleware reads it — a body field, or a header. */
100
+ readonly attach: (body: Record<string, unknown>) => {
101
+ body: Record<string, unknown>;
102
+ headers: Record<string, string>;
103
+ };
104
+ }
105
+
106
+ /** No check at all: what the screen does when the turnstile capability is not composed. */
107
+ const NO_CHECK: HumanityCheck = { widget: null, pending: false, attach: (body) => ({ body, headers: {} }) };
108
+
109
+ export interface SignInScreenProps {
110
+ /**
111
+ * The translator this screen renders through.
112
+ *
113
+ * A prop for the reason `fetch` and `redirect` are: what a screen says in a second language is a
114
+ * *rendered* fact no assertion about source text can reach. Absent, the screen reads the provider a
115
+ * `TranslatorProvider` mounted, and with no provider it reads {@link EN}.
116
+ */
117
+ readonly t?: Translator;
118
+ /** The auth capability's projection. */
119
+ readonly auth: AuthProjection;
120
+ /** The humanity check. Absent means none is composed. */
121
+ readonly check?: HumanityCheck;
122
+ /** The panel beside the form. Absent means one column. */
123
+ readonly brand?: ReactNode;
124
+ /** The compact mark, shown where the panel is not. */
125
+ readonly mark?: ReactNode;
126
+ /** The fetch to use. Undefined in the browser, which is the point of it being optional. */
127
+ readonly fetch?: AuthFetch;
128
+ /** This page's origin, which the callback URL is built against. */
129
+ readonly origin?: string;
130
+ /** How the browser leaves for a provider. Injected so a test never navigates. */
131
+ readonly redirect?: (url: string) => void;
132
+ }
133
+
134
+ /*
135
+ * ─────────────────────────────────────────────────────────────────────────────
136
+ * THE PROVIDER MARKS, AND THE TERMS THEY SHIP UNDER.
137
+ *
138
+ * These are trademarks, not icons. **Nothing in this block is covered by Pithy's MIT licence** — each
139
+ * mark belongs to the company it names, and using it is governed by that company's brand terms rather
140
+ * than by ours. They are shipped because a sign-in button without a mark is a worse first screen than
141
+ * one with it, and because the alternative is every adopter reaching for the wrong asset (see the note
142
+ * about Font Awesome below). What you are responsible for is checking your use against the terms
143
+ * linked beside each mark before you ship.
144
+ *
145
+ * **The rules are opposite from one provider to the next, and that is why these are four components
146
+ * rather than one parameterised `<Mark provider="…" />`.** A shared abstraction would have to encode
147
+ * "unless it is Google" somewhere, and the first tidy-up would lose it. The rule lives beside the
148
+ * asset it governs. Keep it there.
149
+ *
150
+ * **Never hand-draw one.** A wrong-shaped official logo on a credentials page is what a phishing page
151
+ * looks like. Every path below is the vendor's own geometry; if you add a provider and cannot source
152
+ * accurate path data for it, ship that button with no mark rather than an approximation — the screen
153
+ * already renders label-only buttons correctly.
154
+ *
155
+ * **The trap: Font Awesome's `brands/google` is a monochrome single-path G.** It is the obvious thing
156
+ * to reach for, it is in the package most projects already have, and it is the wrong asset for a
157
+ * sign-in button: Google's guidelines require the four-colour mark there. The same goes for any other
158
+ * icon set's "google" glyph. If a mark below ever becomes a `currentColor` single path, that is the
159
+ * mistake, not a simplification.
160
+ * ─────────────────────────────────────────────────────────────────────────────
161
+ */
162
+
163
+ /**
164
+ * GitHub's Invertocat.
165
+ *
166
+ * `currentColor`, and permitted to be: GitHub's terms ask that the mark not be modified or recoloured
167
+ * *into another colour*, and the monochrome Invertocat is the form they publish for exactly this. So
168
+ * it inverts with the theme for free — ink on the light surface, parchment on the dark one — with no
169
+ * second asset and nothing for a theme toggle to wire up.
170
+ *
171
+ * Terms: https://github.com/logos
172
+ */
173
+ function GithubMark(): ReactNode {
174
+ return (
175
+ <svg className="auth__mark" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
176
+ <path
177
+ fill="currentColor"
178
+ d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
179
+ />
180
+ </svg>
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Google's four-colour "G", at the official path data.
186
+ *
187
+ * **The four `fill`s are literals and must stay literals.** Google's brand terms forbid recolouring the
188
+ * mark, so this is the one place on this screen where a colour does not answer the theme and does not
189
+ * come from a token. Blue, green, yellow, red, in that order, on a `0 0 48 48` grid — the proportions
190
+ * the mark is published at, scaled by `width`/`height` rather than by editing the path.
191
+ *
192
+ * Google's guidelines also govern the *button*: clear space around the mark, a minimum size, and the
193
+ * words "Sign in with Google" or "Continue with Google" rather than a bare logo. This screen's
194
+ * `aria-label` carries the second of those; check the first two against your own styling if you change
195
+ * `.auth__provider`.
196
+ *
197
+ * Terms: https://developers.google.com/identity/branding-guidelines
198
+ */
199
+ function GoogleMark(): ReactNode {
200
+ return (
201
+ <svg className="auth__mark" width="16" height="16" viewBox="0 0 48 48" aria-hidden="true">
202
+ <path
203
+ fill="#4285F4"
204
+ d="M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.39 6.64v5.52h7.11c4.16-3.83 6.56-9.47 6.56-16.17z"
205
+ />
206
+ <path
207
+ fill="#34A853"
208
+ d="M24 46c5.94 0 10.92-1.97 14.56-5.33l-7.11-5.52c-1.97 1.32-4.49 2.1-7.45 2.1-5.73 0-10.58-3.87-12.31-9.07H4.34v5.7C7.96 41.07 15.4 46 24 46z"
209
+ />
210
+ <path
211
+ fill="#FBBC05"
212
+ d="M11.69 28.18C11.25 26.86 11 25.45 11 24s.25-2.86.69-4.18v-5.7H4.34C2.85 17.09 2 20.45 2 24s.85 6.91 2.34 9.88l7.35-5.7z"
213
+ />
214
+ <path
215
+ fill="#EA4335"
216
+ d="M24 10.75c3.23 0 6.13 1.11 8.41 3.29l6.31-6.31C34.91 4.18 29.93 2 24 2 15.4 2 7.96 6.93 4.34 14.12l7.35 5.7c1.73-5.2 6.58-9.07 12.31-9.07z"
217
+ />
218
+ </svg>
219
+ );
220
+ }
221
+
222
+ /**
223
+ * The Apple logo, `currentColor` — and unlike GitHub's, that is a narrow permission rather than a free
224
+ * one.
225
+ *
226
+ * Apple's terms allow the logo in **black or white only**. `--pithy-fg` is near-black on the light
227
+ * surface and near-parchment on the dark one, so `currentColor` resolves to the two colours Apple
228
+ * permits and nothing else. **If you declare a `--fg` that is neither**, this mark inherits it and
229
+ * stops conforming — override `.auth__mark` for this button, do not recolour the page and hope.
230
+ *
231
+ * Apple additionally specifies the whole button for "Sign in with Apple": its background, corner
232
+ * radius, minimum size, and the wording. Pithy's `.auth__provider` is a generic secondary button and
233
+ * makes no claim to satisfy that — read the guidelines before you enable this provider in production.
234
+ *
235
+ * Terms: https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple
236
+ */
237
+ function AppleMark(): ReactNode {
238
+ return (
239
+ <svg className="auth__mark" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
240
+ <path
241
+ fill="currentColor"
242
+ d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
243
+ />
244
+ </svg>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Meta's "f" badge, in Facebook Blue — fixed, like Google's, not inherited.
250
+ *
251
+ * Meta's terms fix the colour: the blue mark on a light field, or the white mark on a dark one, and
252
+ * never a recolour. The published path is the badge with the "f" cut *out* of it, so a single fill
253
+ * would let whatever is behind show through the letter — and on a dark surface that is a near-black
254
+ * "f" on blue, which is neither of the two forms they permit. The white disc behind it is the mark's
255
+ * own outer edge (r=12 on a `0 0 24 24` grid), so what renders is the blue-on-white form at every
256
+ * width and in both themes. It is the mark placed on a white field, not the mark altered.
257
+ *
258
+ * Terms: https://about.meta.com/brand/resources/facebookapp/logo
259
+ */
260
+ function FacebookMark(): ReactNode {
261
+ return (
262
+ <svg className="auth__mark" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
263
+ <circle cx="12" cy="12" r="12" fill="#FFFFFF" />
264
+ <path
265
+ fill="#1877F2"
266
+ d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
267
+ />
268
+ </svg>
269
+ );
270
+ }
271
+
272
+ /**
273
+ * The providers `virtual:pithy/auth` can name, in the order they render, each with its mark.
274
+ *
275
+ * Only the ones enabled in `pithy.config.ts` appear at all — there is no CLI flag for this and never
276
+ * will be, because a flag would freeze the list into scaffolded code at the moment you ran the command.
277
+ * Config is the source of truth; a redeploy is the only step.
278
+ */
279
+ const SOCIAL: readonly { id: string; label: string; mark: ReactNode }[] = [
280
+ { id: "google", label: "Google", mark: <GoogleMark /> },
281
+ { id: "github", label: "GitHub", mark: <GithubMark /> },
282
+ { id: "apple", label: "Apple", mark: <AppleMark /> },
283
+ { id: "facebook", label: "Facebook", mark: <FacebookMark /> },
284
+ ];
285
+
286
+ /** Why a provider button did not take you anywhere. Two faults, because they need two sentences. */
287
+ type Refusal = { provider: string; reason: "unconfigured" | "silent" } | null;
288
+
289
+ function refusalText(t: Translator, refusal: NonNullable<Refusal>): string {
290
+ const key = refusal.reason === "unconfigured" ? "auth/sign_in.provider_unconfigured" : "auth/sign_in.provider_silent";
291
+ return t.t(key, { provider: refusal.provider });
292
+ }
293
+
294
+ /**
295
+ * The frame. One shape in every state, so nothing on screen moves when the state changes.
296
+ *
297
+ * `data-brand` is what lets the stylesheet lay out an empty slot correctly: with no panel there is no
298
+ * second column to split, and the form centres on the page instead of hugging one edge of it.
299
+ */
300
+ function Frame(props: { brand: ReactNode; mark: ReactNode; children: ReactNode }): ReactNode {
301
+ return (
302
+ <div className="auth" data-brand={props.brand ? "set" : "none"}>
303
+ {props.brand ? <aside className="auth__brand">{props.brand}</aside> : null}
304
+ <section className="auth__credentials">
305
+ <div className="auth__form">
306
+ {/* The mark, for the widths where the panel is not rendered. CSS decides which; there is no
307
+ JavaScript branch on width, because a second tree is a second thing to keep correct. */}
308
+ {props.mark ? <div className="auth__form-mark">{props.mark}</div> : null}
309
+ {props.children}
310
+ </div>
311
+ </section>
312
+ </div>
313
+ );
314
+ }
315
+
316
+ /**
317
+ * The screen, with every seam as a prop.
318
+ *
319
+ * **One way in: a magic link.** This shipped with "Email me a code" beside it. Two passwordless paths
320
+ * on one screen is two things to explain, two surfaces to rate-limit, and two inboxes' worth of mail
321
+ * for one intent. The link is the one that needs no retyping, so it is the one that stayed — and
322
+ * `routes/pithy/otp.tsx` is still there if you would rather have the code.
323
+ *
324
+ * **Social is never gated by the humanity check.** An OAuth redirect carries no token and the provider
325
+ * runs its own bot defense, so the check disables the submit and nothing else — which is what
326
+ * `@pithy-sh/auth` already assumes when it stacks the check on the magic-link route only.
327
+ */
328
+ export function SignInScreen(props: SignInScreenProps): ReactNode {
329
+ const { auth } = props;
330
+ // Called unconditionally, and chosen from afterwards: `props.t ?? useTranslator(EN)` would skip the
331
+ // hook whenever the prop is passed, which is a hook count that changes between renders.
332
+ const baked = useTranslator(EN);
333
+ const t = props.t ?? baked;
334
+ const check = props.check ?? NO_CHECK;
335
+ // Where the auth routes are, and the fetch to reach them with. Everything else about the request —
336
+ // the base-path join, the cookie mode, the same-origin refusal, the failure directions — belongs to
337
+ // `@pithy-sh/auth`, so it can still be fixed after this file has been copied into your repository.
338
+ const client = { basePath: auth.basePath, fetch: props.fetch };
339
+ const origin = props.origin ?? window.location.origin;
340
+ const redirect =
341
+ props.redirect ??
342
+ ((url: string) => {
343
+ window.location.href = url;
344
+ });
345
+
346
+ const [email, setEmail] = useState("");
347
+ const [sent, setSent] = useState(false);
348
+ const [busy, setBusy] = useState(false);
349
+ const [refusal, setRefusal] = useState<Refusal>(null);
350
+
351
+ const offered = SOCIAL.filter((provider) => auth.providers[provider.id]);
352
+
353
+ async function sendLink(event: FormEvent): Promise<void> {
354
+ event.preventDefault();
355
+ setBusy(true);
356
+ await sendMagicLink({ email, callbackURL: `${origin}${callbackPath}` }, { ...client, gate: check.attach });
357
+ setBusy(false);
358
+ // Always the same answer, whether or not the address is registered. Telling the two apart is an
359
+ // enumeration oracle, so neither the copy nor the timing of it confirms either way.
360
+ setSent(true);
361
+ }
362
+
363
+ async function social(provider: { id: string; label: string }): Promise<void> {
364
+ setRefusal(null);
365
+ // No humanity check here, deliberately: the redirect carries no token and the provider runs its own
366
+ // bot defense. `startSocialSignIn` drops one even if it is passed, so this cannot be forgotten.
367
+ const started = await startSocialSignIn({ provider: provider.id, callbackURL: `${origin}${callbackPath}` }, client);
368
+ if (started.kind === "authorize") {
369
+ redirect(started.url);
370
+ return;
371
+ }
372
+ // A URL we could read but could not follow means the provider is on with no credential behind it;
373
+ // anything else means our own server did not answer. Different faults, different copy.
374
+ setRefusal({ provider: provider.label, reason: started.kind === "unconfigured" ? "unconfigured" : "silent" });
375
+ }
376
+
377
+ if (sent) {
378
+ return (
379
+ <Frame brand={props.brand} mark={props.mark}>
380
+ <h1>{t.t("auth/sign_in.sent.title")}</h1>
381
+ <p className="muted">{t.t("auth/sign_in.sent.body")}</p>
382
+ </Frame>
383
+ );
384
+ }
385
+
386
+ return (
387
+ <Frame brand={props.brand} mark={props.mark}>
388
+ <h1>{t.t("auth/sign_in.title")}</h1>
389
+
390
+ {/* The providers first, and the refusal line directly under them — a failure belongs beside the
391
+ control that caused it, not at the foot of the screen where it reads as being about the form. */}
392
+ {offered.length > 0 && (
393
+ <>
394
+ <div className="auth__providers">
395
+ {offered.map((provider) => (
396
+ <button
397
+ key={provider.id}
398
+ type="button"
399
+ className="secondary auth__provider"
400
+ aria-label={t.t("auth/sign_in.provider.label", { provider: provider.label })}
401
+ onClick={() => void social(provider)}
402
+ >
403
+ {/* The mark is decorative and `aria-hidden`; the visible label is the one word. The
404
+ `aria-label` carries what the button *does*, because "Google" alone announces a
405
+ company rather than an action — and it contains the visible word, so voice control
406
+ still matches "click Google" (WCAG 2.5.3, Label in Name). */}
407
+ {provider.mark}
408
+ <span>{provider.label}</span>
409
+ </button>
410
+ ))}
411
+ </div>
412
+ {refusal && <p className="auth__failed">{refusalText(t, refusal)}</p>}
413
+ <div className="divider">{t.t("auth/sign_in.divider")}</div>
414
+ </>
415
+ )}
416
+
417
+ <form className="stack" onSubmit={(event) => void sendLink(event)}>
418
+ <div>
419
+ <label htmlFor="email">{t.t("auth/sign_in.email.label")}</label>
420
+ <input
421
+ id="email"
422
+ type="email"
423
+ name="email"
424
+ autoComplete="email"
425
+ required
426
+ value={email}
427
+ onChange={(event) => setEmail(event.target.value)}
428
+ />
429
+ </div>
430
+
431
+ {/* The host that gives the widget the column's width to fill. See `.auth__check`, and note that
432
+ the host is only half of it — `turnstile.tsx` asks for the `flexible` size. */}
433
+ <div className="auth__check">{check.widget}</div>
434
+
435
+ <button type="submit" disabled={busy || !email || check.pending}>
436
+ {t.t("auth/sign_in.submit")}
437
+ </button>
438
+ </form>
439
+
440
+ {/* Reassurance, not navigation. Passwordless has no sign-up screen to point at — sign-up *is*
441
+ signing in when the capability allows it — so an anchor here would be a 404 dressed as an
442
+ affordance. `<strong>` rather than a class: the answer is the half that carries weight, and
443
+ emphasis is what the element means. */}
444
+ <p className="auth__signup">
445
+ {auth.signUpEnabled ? (
446
+ <>
447
+ {t.t("auth/sign_in.signup.prompt")} <strong>{t.t("auth/sign_in.signup.answer")}</strong>
448
+ </>
449
+ ) : (
450
+ t.t("auth/sign_in.signup.closed")
451
+ )}
452
+ </p>
453
+ </Frame>
454
+ );
455
+ }
456
+
457
+ /** The wiring: the real projection, the real widget, and your two slots. */
458
+ export default function SignIn(): ReactNode {
459
+ const [token, setToken] = useState<string | null>(null);
460
+ const onToken = useCallback((value: string | null) => setToken(value), []);
461
+ const check = useMemo<HumanityCheck>(
462
+ () => ({
463
+ widget: <Turnstile onToken={onToken} />,
464
+ pending: turnstilePending(token),
465
+ attach: (body) => turnstileRequest(body, token),
466
+ }),
467
+ [onToken, token],
468
+ );
469
+ return <SignInScreen auth={authConfig} check={check} brand={BRAND} mark={MARK} />;
470
+ }
@@ -0,0 +1,183 @@
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 {
5
+ PAYMENTS_HOSTED_RAILS,
6
+ type PaymentsClientOptions,
7
+ type PaymentsClientRail,
8
+ } from "@pithy-sh/payments/src/client/api";
9
+ import { useSubscription } from "@pithy-sh/payments/src/client/hooks";
10
+ import type { ReactNode } from "react";
11
+ import { failureText, paymentsClient } from "../../payments";
12
+ import { paymentsConfig } from "../../pithy-config";
13
+ import { Link, useScreenPath } from "../../router";
14
+ import "../../pithy-screens.css";
15
+
16
+ export const path = "/subscription";
17
+
18
+ // What the paywall and the pricing screen link to as "what do I already have?". Rename `path` above and
19
+ // both follow, because they read this claim rather than keeping a copy of the string (#393).
20
+ export const role = "subscription";
21
+
22
+ // The entitlements shown are the caller's own; the server resolves them from the session. Under a project
23
+ // that bills organizations they are the organization's, resolved from that same session — either way this
24
+ // screen names no holder and cannot, which is what stops it becoming a way to read somebody else's.
25
+ export const session = "required";
26
+
27
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
28
+
29
+ /**
30
+ * This screen's English, baked in — the only catalog that survives being copied into your repository.
31
+ *
32
+ * The renewal date is not in here, and could not be: a date is `Intl`'s to render, in the reader's own
33
+ * locale, from the translator's `formattingLocale`. What the message carries is the sentence around it.
34
+ */
35
+ const EN = {
36
+ "payments/subscription.subscribed": "You're subscribed.",
37
+ "payments/subscription.empty": "Nothing yet.",
38
+ "payments/subscription.unreadable": "We couldn't check.",
39
+ "payments/subscription.nothing_held": "You don't hold anything on this account.",
40
+ "payments/subscription.loading": "One moment.",
41
+ "payments/subscription.holding.ended": "Ended.",
42
+ "payments/subscription.holding.ended_not_renewing": "Ended, and not renewing.",
43
+ "payments/subscription.holding.kept": "Yours to keep.",
44
+ "payments/subscription.holding.renews": "Renews {date}.",
45
+ "payments/subscription.manage": "Manage billing",
46
+ "payments/subscription.apple": "Bought on the App Store",
47
+ "payments/subscription.google": "Bought on Google Play",
48
+ "payments/subscription.more": "See what else there is",
49
+ } satisfies MessageCatalog;
50
+
51
+ /**
52
+ * What to say about one entitlement.
53
+ *
54
+ * `granted` comes first, and that ordering is the whole point: the server applies the expiry itself on every
55
+ * read, so a row can come back `granted: false` while its own `expiresAt` still says nothing is wrong — a
56
+ * refunded non-consumable is `{ granted: false, expiresAt: null }`, and reading only the date printed
57
+ * "Yours to keep." over a purchase the holder no longer has.
58
+ */
59
+ function holding(t: Translator, entitlement: { granted: boolean; expiresAt: string | null }): string {
60
+ if (!entitlement.granted) {
61
+ return entitlement.expiresAt === null
62
+ ? t.t("payments/subscription.holding.ended")
63
+ : t.t("payments/subscription.holding.ended_not_renewing");
64
+ }
65
+ if (entitlement.expiresAt === null) return t.t("payments/subscription.holding.kept");
66
+ // The app's locale, not the browser's. A bare `toLocaleDateString()` follows whatever language the
67
+ // device is set to, so a reader who chose Spanish inside a Spanish app still read an English date —
68
+ // and on a right-to-left locale the two disagreed about direction as well.
69
+ return t.t("payments/subscription.holding.renews", { date: t.formatDate(new Date(entitlement.expiresAt)) });
70
+ }
71
+
72
+ export interface SubscriptionScreenProps {
73
+ /**
74
+ * The translator this screen renders through.
75
+ *
76
+ * A prop for the reason `client` is: what a screen says in a second language is a *rendered* fact no
77
+ * assertion about source text can reach. Absent, the screen reads the provider a `TranslatorProvider`
78
+ * mounted, and with no provider it reads {@link EN}.
79
+ */
80
+ readonly t?: Translator;
81
+ /**
82
+ * Which rails this project sells through — `paymentsConfig.rails`.
83
+ *
84
+ * Keyed by the rail union rather than by `string`, and that is a gate: a rail added to the package
85
+ * whose projection nobody widened stops compiling here, instead of reading `undefined` and rendering
86
+ * nothing. `noUncheckedIndexedAccess` would hide that behind a `string` key.
87
+ */
88
+ readonly rails: Readonly<Record<PaymentsClientRail, boolean>>;
89
+ /** Where the payments routes are, and the fetch to reach them with. Injected so a test never navigates. */
90
+ readonly client?: PaymentsClientOptions;
91
+ /**
92
+ * Where "see what else there is" points — the path the paywall screen declares, read through the
93
+ * role it claims rather than written out here. A literal survives the rename and stops answering
94
+ * (#393).
95
+ */
96
+ readonly paywallPath: string;
97
+ }
98
+
99
+ /**
100
+ * The subscription screen, taking its projection rather than reading it.
101
+ *
102
+ * A prop for the same reason `SignInScreen` takes one: which rails are on decides which buttons exist,
103
+ * and that is a *rendered* fact no assertion about source text can reach. `subscription.test.tsx` mounts
104
+ * this against a Paddle-only project and looks for the button — which is the whole of #336, checked the
105
+ * way a user would check it.
106
+ */
107
+ export function SubscriptionScreen({ rails, client, paywallPath, t: given }: SubscriptionScreenProps): ReactNode {
108
+ const { entitlements, subscribed, loading, manage, manageStore, managing, failure, readFailure } =
109
+ useSubscription(client);
110
+ // Called unconditionally, and chosen from afterwards: `given ?? useTranslator(EN)` would skip the hook
111
+ // whenever the prop is passed, which is a hook count that changes between renders.
112
+ const baked = useTranslator(EN);
113
+ const t = given ?? baked;
114
+
115
+ if (loading) return <p className="muted">{t.t("payments/subscription.loading")}</p>;
116
+
117
+ return (
118
+ <main className="screen">
119
+ {/* A read that failed is not an account that holds nothing. "Nothing yet." over an unreachable
120
+ Worker tells a paying subscriber they have no subscription, and this screen is one click from
121
+ the paywall that would sell them a second one. So the failure gets its own heading and the
122
+ empty state is never rendered from an answer nobody received. */}
123
+ <h1>
124
+ {readFailure
125
+ ? t.t("payments/subscription.unreadable")
126
+ : subscribed
127
+ ? t.t("payments/subscription.subscribed")
128
+ : t.t("payments/subscription.empty")}
129
+ </h1>
130
+
131
+ {readFailure ? (
132
+ <p className="muted">{failureText(t, readFailure)}</p>
133
+ ) : entitlements.length === 0 ? (
134
+ <p className="muted">{t.t("payments/subscription.nothing_held")}</p>
135
+ ) : (
136
+ <div className="stack">
137
+ {entitlements.map((entitlement) => (
138
+ <p key={entitlement.key}>
139
+ <strong>{entitlement.key}</strong> <span className="muted">{holding(t, entitlement)}</span>
140
+ </p>
141
+ ))}
142
+ </div>
143
+ )}
144
+
145
+ {failure && <p className="muted">{failureText(t, failure)}</p>}
146
+
147
+ {/* Managing a subscription belongs to whoever sold it, under their own rules. A hosted rail's portal
148
+ is a session the server mints; Apple's and Google's are pages in their own stores, and a web page
149
+ cannot cancel a StoreKit or Play Billing subscription however much it would like to. */}
150
+ <div className="stack">
151
+ {/* Every hosted rail mints a portal, and the server picks whichever one this caller actually
152
+ bought on. The list is the package's, not this file's: this gate was written out by hand
153
+ twice and was one rail short both times (#336). A rail added to Pithy now reaches this
154
+ screen without an edit, in a repo that copied it a year ago. */}
155
+ {PAYMENTS_HOSTED_RAILS.some((rail) => rails[rail]) && (
156
+ <button type="button" disabled={managing} onClick={() => void manage()}>
157
+ {t.t("payments/subscription.manage")}
158
+ </button>
159
+ )}
160
+ {/* Named one at a time, and correctly so: each store has its own sentence, and neither is a set. */}
161
+ {rails.apple && (
162
+ <button type="button" className="secondary" onClick={() => manageStore("apple")}>
163
+ {t.t("payments/subscription.apple")}
164
+ </button>
165
+ )}
166
+ {rails.google && (
167
+ <button type="button" className="secondary" onClick={() => manageStore("google")}>
168
+ {t.t("payments/subscription.google")}
169
+ </button>
170
+ )}
171
+ <Link className="muted" to={paywallPath}>
172
+ {t.t("payments/subscription.more")}
173
+ </Link>
174
+ </div>
175
+ </main>
176
+ );
177
+ }
178
+
179
+ export default function Subscription(): ReactNode {
180
+ // The paywall names itself; this reads the path it declared rather than keeping a copy (#393).
181
+ const paywallPath = useScreenPath("paywall");
182
+ return <SubscriptionScreen rails={paymentsConfig.rails} client={paymentsClient} paywallPath={paywallPath} />;
183
+ }