@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,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
|
+
});
|