@usethrottle/auth 0.1.2 → 0.2.1

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/CLAUDE.md CHANGED
@@ -36,6 +36,31 @@ points: `.` (core), `./react`, `./forms` — see `exports` in `package.json`.
36
36
  empty `useOrders`/`useAddresses` results by adding client-side workarounds;
37
37
  see the README's "Email verification and the claim cutoff" section.
38
38
 
39
+ ## Components layer (`src/react/components/`, `./components` entry)
40
+
41
+ - **Step-up interception already exists in the core.** `client.ts:168`
42
+ intercepts `403 step_up_required` and calls `onStepUpRequired()` before
43
+ retrying the call once. `ThrottleProvider` supplies that callback (it opens
44
+ `StepUpDialog` and resolves the promise from the dialog's submit/cancel) —
45
+ it does not, and must not, re-implement the interception itself.
46
+ - **`ThrottleProvider` composes `ThrottleAuthProvider`, it does not replace
47
+ it.** It builds the `ThrottleAuth` client itself (so it can pass
48
+ `onStepUpRequired` at construction time) and hands that client to
49
+ `ThrottleAuthProvider` via the `auth` injection prop that already exists for
50
+ this purpose. Every hook from `./react` (`useAuth`, `useCustomer`,
51
+ `useOrders`, …) keeps working underneath unchanged.
52
+ - **Every rule in `styles.ts` must stay a single-class selector.** A merchant
53
+ className from `appearance.elements` lands on the same element as the
54
+ shipped class, so an override wins only via equal specificity + source
55
+ order. A descendant selector (e.g. `.throttle-card .throttle-button`) would
56
+ silently outrank a merchant's override no matter what class they add.
57
+ - **`THROTTLE_ELEMENT_KEYS` (in `appearance.tsx`) is public API.** It is the
58
+ exhaustive list of every `data-throttle-element` a component may render.
59
+ Adding a rendered element without adding its key here passes typecheck and
60
+ build but fails `element-keys.test.tsx`, which mounts a sweep of components
61
+ and asserts every `data-throttle-element` found is in the list — the element
62
+ would otherwise be unreachable through `appearance.elements` for merchants.
63
+
39
64
  ## Conventions
40
65
 
41
66
  - Every exported function's public surface is asserted in `src/exports.test.ts`
package/README.md CHANGED
@@ -189,6 +189,264 @@ function Orders() {
189
189
  - `useSubscriptions(page?)` — `cancel(id, { atPeriodEnd })`, `pause(id)`,
190
190
  `resume(id)`.
191
191
 
192
+ ## Drop-in components
193
+
194
+ A third entry point, `@usethrottle/auth/components`, ships pre-styled React
195
+ components on top of everything above — a sign-in form, an account dropdown, a
196
+ full account dashboard — so most storefronts never touch `useAuth`/`useCustomer`
197
+ directly. It re-exports the whole `./react` hooks surface too, so this is the
198
+ only import path a component-using storefront needs.
199
+
200
+ ### Quick start
201
+
202
+ ```tsx
203
+ import { ThrottleProvider, UserButton, SignInButton, SignedIn, SignedOut }
204
+ from '@usethrottle/auth/components';
205
+
206
+ export default function Layout({ children }) {
207
+ return (
208
+ <ThrottleProvider publishableKey={process.env.NEXT_PUBLIC_THROTTLE_PK}>
209
+ <nav>
210
+ <SignedIn><UserButton /></SignedIn>
211
+ <SignedOut><SignInButton /></SignedOut>
212
+ </nav>
213
+ {children}
214
+ </ThrottleProvider>
215
+ );
216
+ }
217
+ ```
218
+
219
+ `<ThrottleProvider>` replaces `<ThrottleAuthProvider>` from `./react` — it
220
+ takes the same `publishableKey`/`baseUrl`/`storage`/`claimCartOnSignIn` config,
221
+ plus `appearance` and `localization` (below) and `injectStyles`. It composes
222
+ the existing provider rather than replacing it, so every hook from `./react`
223
+ keeps working underneath unchanged.
224
+
225
+ `<SignedIn>`, `<SignedOut>` and `<Protect requireVerified fallback={...}>` are
226
+ conditional-render guards — they render nothing while the session is still
227
+ loading, so a navbar never flashes the wrong state during session restore.
228
+ `<Protect>` additionally gates on `emailVerified` when `requireVerified` is set.
229
+
230
+ ### Components
231
+
232
+ | Component | Purpose | Key props |
233
+ | --------- | ------- | --------- |
234
+ | `SignIn` | Email/password sign-in form. | `onSuccess()`, `onForgotPassword()`, `onSignUp()`, `appearance`, `localization` |
235
+ | `SignUp` | Registration form. | `onSuccess()`, `onSignIn()`, `appearance`, `localization` |
236
+ | `ForgotPassword` | Requests a reset-link email. | `onBack()`, `appearance`, `localization` |
237
+ | `ResetPassword` | Consumes a reset-link token, sets a new password. | `token`, `onSuccess()`, `appearance`, `localization` |
238
+ | `VerifyEmail` | Consumes a verification-link token, or offers a resend prompt with no token. | `token`, `onSuccess()`, `appearance`, `localization` |
239
+ | `AuthFlow` | Switches between all five auth views above, with optional hash-based routing. | `initialView` / `view` + `onViewChange` (controlled), `routing: 'virtual' \| 'hash'`, `afterSignIn()`, `afterSignUp()` |
240
+ | `AuthModal` | `AuthFlow` in an overlay. | `open`, `onClose()`, `initialView`, `afterSignIn()` |
241
+ | `SignInButton` | Opens `AuthModal` (`mode="modal"`, default) or links to a URL (`mode="redirect"`). | `mode`, `url`, `initialView`, `asChild`, `afterSignIn()` |
242
+ | `SignOutButton` | Signs out; `everywhere` revokes every session (may prompt step-up). | `everywhere`, `onSignedOut()` |
243
+ | `UserButton` | Signed-in identity dropdown: manage account, sign out. Renders nothing while signed out. | `mode: 'modal' \| 'navigate'`, `accountUrl`, `tabs`, `afterSignOut()` |
244
+ | `AccountDashboard` | Tabbed account shell composing all nine panels below. | `tabs` (defaults to all seven), `tab` / `onTabChange` (controlled), `routing: 'virtual' \| 'hash'` |
245
+ | `VerifyEmailBanner` | Persistent "verify your email" banner with a resend button. | `appearance`, `localization` |
246
+ | `ProfilePanel` | Name/phone/company/marketing-opt-in form. | `appearance`, `localization` |
247
+ | `AddressesPanel` | List, add, edit, delete, and set-default addresses. | `appearance`, `localization` |
248
+ | `PaymentMethodsPanel` | List saved cards, set default, remove. Read-only for adding — see Known limits. | `appearance`, `localization` |
249
+ | `OrdersPanel` | Order list; drills into `OrderDetail` internally. | `onViewSubscription(id)`, `appearance`, `localization` |
250
+ | `OrderDetail` | One order's line items, totals, and addresses. | `orderId`, `onBack()`, `onViewSubscription(id)`, `appearance`, `localization` |
251
+ | `InvoicesPanel` | Invoice list; drills into `InvoiceDetail` internally. | `appearance`, `localization` |
252
+ | `InvoiceDetail` | One invoice's totals and a PDF download button. | `appearance`, `localization` |
253
+ | `SubscriptionsPanel` | Subscription list: cancel (now or at period end), pause, resume. | `appearance`, `localization` |
254
+ | `SecurityPanel` | Change-password form and active-device session list. | `appearance`, `localization` |
255
+
256
+ Every panel behind the buyer plane's verified-only routes (`OrdersPanel`,
257
+ `InvoicesPanel`, `PaymentMethodsPanel`, `SubscriptionsPanel`) renders the
258
+ [claim-cutoff](#email-verification-and-the-claim-cutoff) empty state
259
+ automatically while `emailVerified` is `false` — you do not need to gate them
260
+ yourself, though `<Protect requireVerified>` is still useful for gating an
261
+ entire route. `AddressesPanel` is not claim-cutoff gated — the server does not
262
+ require a verified email to read or write addresses.
263
+
264
+ ### Theming
265
+
266
+ Two independent knobs, both accepted as an `appearance` prop on
267
+ `ThrottleProvider` (applies to everything below it) or on any individual
268
+ component (merges with, and overrides, the provider's value — so a component
269
+ prop only needs to carry what's different):
270
+
271
+ ```tsx
272
+ <ThrottleProvider
273
+ publishableKey={pk}
274
+ appearance={{
275
+ theme: 'auto', // 'light' | 'dark' | 'auto' (follows prefers-color-scheme)
276
+ variables: {
277
+ colorPrimary: '#7c3aed',
278
+ colorPrimaryForeground: '#ffffff',
279
+ borderRadius: '6px',
280
+ fontFamily: 'Inter, system-ui, sans-serif',
281
+ },
282
+ elements: {
283
+ // key → className appended alongside the built-in class
284
+ 'signIn.submitButton': 'my-brand-button',
285
+ card: 'my-brand-card',
286
+ },
287
+ components: {
288
+ // swap a primitive wholesale instead of just restyling it
289
+ Spinner: MyBrandSpinner,
290
+ },
291
+ }}
292
+ >
293
+ ```
294
+
295
+ **`variables`** — CSS custom properties, set on the `.throttle-root` wrapper
296
+ every top-level component renders:
297
+
298
+ | Variable key | CSS custom property | Default (light) |
299
+ | ------------ | -------------------- | ---------------- |
300
+ | `colorPrimary` | `--throttle-color-primary` | `#18181b` |
301
+ | `colorPrimaryForeground` | `--throttle-color-primary-foreground` | `#ffffff` |
302
+ | `colorBackground` | `--throttle-color-background` | `#ffffff` |
303
+ | `colorSurface` | `--throttle-color-surface` | `#f8fafc` |
304
+ | `colorForeground` | `--throttle-color-foreground` | `#0e1116` |
305
+ | `colorMuted` | `--throttle-color-muted` | `#eceef2` |
306
+ | `colorMutedForeground` | `--throttle-color-muted-foreground` | `#5b6472` |
307
+ | `colorBorder` | `--throttle-color-border` | `#e2e8f0` |
308
+ | `colorDanger` | `--throttle-color-danger` | `#c2321f` |
309
+ | `colorDangerSurface` | `--throttle-color-danger-surface` | `#fef2f2` |
310
+ | `colorDangerBorder` | `--throttle-color-danger-border` | `#fecaca` |
311
+ | `colorSuccess` | `--throttle-color-success` | `#127a4a` |
312
+ | `colorSuccessSurface` | `--throttle-color-success-surface` | `#ecfdf5` |
313
+ | `colorSuccessBorder` | `--throttle-color-success-border` | `#a7f3d0` |
314
+ | `colorWarning` | `--throttle-color-warning` | `#92400e` |
315
+ | `colorWarningSurface` | `--throttle-color-warning-surface` | `#fffbeb` |
316
+ | `colorWarningBorder` | `--throttle-color-warning-border` | `#fde68a` |
317
+ | `fontFamily` | `--throttle-font-family` | system font stack |
318
+ | `fontSize` | `--throttle-font-size` | `16px` |
319
+ | `borderRadius` | `--throttle-radius` | `12px` |
320
+ | `borderWidth` | `--throttle-border-width` | `1px` |
321
+ | `spacing` | `--throttle-spacing` | `8px` |
322
+ | `shadowCard` | `--throttle-shadow-card` | `0 1px 3px rgba(15,23,42,.08), 0 1px 2px rgba(15,23,42,.04)` |
323
+ | `shadowPopover` | `--throttle-shadow-popover` | `0 10px 24px rgba(15,23,42,.12), 0 2px 6px rgba(15,23,42,.06)` |
324
+ | `transition` | `--throttle-transition` | `150ms cubic-bezier(.2,0,.2,1)` |
325
+ | `zModal` | `--throttle-z-modal` | `2147483000` |
326
+ | `zPopover` | `--throttle-z-popover` | `2147482000` |
327
+
328
+ ### Set `colorPrimary` and `colorPrimaryForeground` together
329
+
330
+ `colorPrimaryForeground` is the text colour drawn on top of `colorPrimary`. They
331
+ are separate variables, so overriding only the first leaves the theme's own
332
+ foreground in place — white in light, near-black in dark — and the pair is not
333
+ always readable. A terracotta primary on its own measures 3.65:1 against the
334
+ dark theme's foreground, below the 4.5:1 WCAG asks for button text.
335
+
336
+ In development the components measure the pair and warn once in the console when
337
+ it falls short, naming the ratio and the theme it fails in. Nothing is logged in
338
+ production builds, and colours we cannot parse (`var(--brand)`, `rgb(...)`,
339
+ named colours) are left alone rather than guessed at.
340
+
341
+ An unrecognized key is dropped rather than passed through — a typo never
342
+ silently reaches the DOM as a custom property that does nothing. `theme: 'dark'`
343
+ swaps every color variable's default; `'auto'` follows
344
+ `prefers-color-scheme` and swaps only when `theme` is left unset.
345
+
346
+ **`elements`** — every element a component renders carries a stable key
347
+ (`'signIn.submitButton'`, `'card'`, `'field.input'`, …) via
348
+ `data-throttle-element` in the DOM, and `THROTTLE_ELEMENT_KEYS` is the
349
+ exhaustive, frozen list of them. Map any key to a className in `elements` and
350
+ it is appended alongside the shipped class — every shipped rule is a single
351
+ class selector, so an appended class of equal specificity wins on source
352
+ order. This means Tailwind utility classes work directly, no wrapper needed:
353
+
354
+ ```tsx
355
+ appearance={{
356
+ elements: {
357
+ 'signIn.submitButton': 'bg-violet-600 hover:bg-violet-700 text-white rounded-lg',
358
+ card: 'shadow-xl rounded-2xl ring-1 ring-violet-100',
359
+ 'field.input': 'focus:ring-2 focus:ring-violet-500',
360
+ },
361
+ }}
362
+ ```
363
+
364
+ If your bundler ever loads Tailwind's stylesheet *after* ours (order no longer
365
+ in your favor), add Tailwind's `!` important prefix (`!bg-violet-600`) rather
366
+ than fighting load order — it's a one-character fix that works regardless of
367
+ which stylesheet lands last.
368
+
369
+ ### Localization
370
+
371
+ ```tsx
372
+ <ThrottleProvider
373
+ publishableKey={pk}
374
+ localization={{
375
+ signIn: { title: 'Welcome back', submitButton: 'Log in' },
376
+ errors: { weak_password: 'Choose a longer password.' },
377
+ }}
378
+ >
379
+ ```
380
+
381
+ Every string in `enUS` (also exported, for reference or as a base to spread
382
+ from) can be overridden at any depth — a component prop only needs the keys it
383
+ changes, and everything else falls back to the default English copy.
384
+
385
+ `errors` is a `code → message` map layered in front of the shipped
386
+ `ThrottleAuthError` → copy mapping: when a call fails, a component looks up
387
+ `errors[err.code]` first and only falls back to the built-in message if that
388
+ key is absent. Use it to customize copy for `weak_password`,
389
+ `too_many_attempts`, or any other `ThrottleAuthError` code from the
390
+ [Errors](#errors) table above, without losing the built-in messages for every
391
+ code you don't override.
392
+
393
+ ### Styles
394
+
395
+ Styles auto-inject on mount by default (`<ThrottleProvider injectStyles>`, true
396
+ unless set otherwise) via a single `<style>` tag, so a plain
397
+ `npm install` + `<ThrottleProvider>` is enough to see fully-styled components.
398
+ For a flash-free SSR paint, disable the runtime injection and import the
399
+ stylesheet directly instead, so it's present in the very first paint:
400
+
401
+ ```tsx
402
+ import '@usethrottle/auth/styles.css';
403
+ // ...
404
+ <ThrottleProvider publishableKey={pk} injectStyles={false}>
405
+ ```
406
+
407
+ ### Next.js App Router
408
+
409
+ Put `<ThrottleProvider>` once, in the root layout. Every drop-in component is
410
+ already a client component internally — nothing under it needs its own
411
+ `'use client'` directive:
412
+
413
+ ```tsx
414
+ // app/layout.tsx
415
+ import { ThrottleProvider } from '@usethrottle/auth/components';
416
+
417
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
418
+ return (
419
+ <html lang="en">
420
+ <body>
421
+ <ThrottleProvider publishableKey={process.env.NEXT_PUBLIC_THROTTLE_PK!}>
422
+ {children}
423
+ </ThrottleProvider>
424
+ </body>
425
+ </html>
426
+ );
427
+ }
428
+ ```
429
+
430
+ ### Known limits
431
+
432
+ - **No add-card control.** There is no buyer-plane route for creating a saved
433
+ payment method (see [What this package does NOT do](#what-this-package-does-not-do)),
434
+ so `PaymentMethodsPanel` only lists, sets a default, and removes — it never
435
+ renders a card-entry form. Add cards through
436
+ [`@usethrottle/payment-methods`](https://usethrottle.dev/docs/developers/payment-methods)
437
+ (a merchant-minted client token, separate from this package), then let
438
+ `PaymentMethodsPanel` (or `useSavedCards()`) pick up the new card on its next
439
+ fetch.
440
+ - **`<SignUp/>`'s success copy is deliberately hedged.** The register endpoint
441
+ returns the same `pending` status, and sends no email, both for a genuinely
442
+ new address and for a repeat attempt on an address that already has an
443
+ account — the server cannot tell your UI which case happened without leaking
444
+ which emails are registered. So the shipped copy reads "If that email is
445
+ new, check your inbox…" rather than "We sent you an email" — the latter
446
+ would be false in the second case. Override `localization.signUp.pendingNotice`
447
+ if you want different wording, but keep it similarly non-committal for the
448
+ same reason.
449
+
192
450
  ## Email verification and the claim cutoff
193
451
 
194
452
  **This is the single most surprising thing about this API — read it before
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ // src/react/forms/messages.ts
4
+ function waitCopy(seconds) {
5
+ if (!seconds || !Number.isFinite(seconds)) return "Please wait a moment and try again.";
6
+ if (seconds < 60) return `Please wait ${Math.ceil(seconds)} seconds and try again.`;
7
+ const minutes = Math.ceil(seconds / 60);
8
+ return `Please wait ${minutes} ${minutes === 1 ? "minute" : "minutes"} and try again.`;
9
+ }
10
+ function messageForError(err) {
11
+ const e = err;
12
+ switch (e?.code) {
13
+ case "invalid_credentials":
14
+ return "That email or password is not correct.";
15
+ case "weak_password":
16
+ return e.message || "Please choose a stronger password.";
17
+ case "too_many_attempts":
18
+ return `Too many attempts. ${waitCopy(e.retryAfter)}`;
19
+ case "verification_required":
20
+ return "Please verify your email address to continue.";
21
+ case "step_up_required":
22
+ return "Please re-enter your password to continue.";
23
+ case "session_revoked":
24
+ return "Your session has ended. Please sign in again.";
25
+ case "token_expired":
26
+ case "token_invalid":
27
+ case "token_already_used":
28
+ return "That link is no longer valid. Please request a new link.";
29
+ case "auth_unavailable":
30
+ return "Accounts are briefly unavailable. Please try again shortly.";
31
+ case "origin_not_allowed":
32
+ case "auth_not_enabled":
33
+ case "invalid_api_key":
34
+ case "environment_mismatch":
35
+ return "Accounts are not available right now. Please contact support.";
36
+ case "cart_already_claimed":
37
+ return "That cart belongs to a different account.";
38
+ case "not_found":
39
+ return "We could not find that.";
40
+ case "payment_method_in_use":
41
+ return e.message || "That payment method is in use and cannot be removed.";
42
+ default:
43
+ return "Something went wrong. Please try again.";
44
+ }
45
+ }
46
+
47
+ export {
48
+ messageForError
49
+ };
50
+ //# sourceMappingURL=chunk-AIXBUDOT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/forms/messages.ts"],"sourcesContent":["import { ThrottleAuthError } from '../../errors';\n\nfunction waitCopy(seconds?: number): string {\n if (!seconds || !Number.isFinite(seconds)) return 'Please wait a moment and try again.';\n if (seconds < 60) return `Please wait ${Math.ceil(seconds)} seconds and try again.`;\n const minutes = Math.ceil(seconds / 60);\n return `Please wait ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} and try again.`;\n}\n\n/**\n * Buyer-facing copy for every code the storefront API returns. Two codes are\n * deliberately NOT passed through from the server: `invalid_credentials` (the\n * server is vague on purpose — enumerating accounts through our own UI would\n * undo that) and anything unrecognised.\n */\nexport function messageForError(err: unknown): string {\n const e = err as Partial<ThrottleAuthError> & { code?: string };\n switch (e?.code) {\n case 'invalid_credentials':\n return 'That email or password is not correct.';\n case 'weak_password':\n // The server's message IS the policy reason (\"Use at least 12 characters\").\n return e.message || 'Please choose a stronger password.';\n case 'too_many_attempts':\n return `Too many attempts. ${waitCopy(e.retryAfter)}`;\n case 'verification_required':\n return 'Please verify your email address to continue.';\n case 'step_up_required':\n return 'Please re-enter your password to continue.';\n case 'session_revoked':\n return 'Your session has ended. Please sign in again.';\n case 'token_expired':\n case 'token_invalid':\n case 'token_already_used':\n return 'That link is no longer valid. Please request a new link.';\n case 'auth_unavailable':\n return 'Accounts are briefly unavailable. Please try again shortly.';\n case 'origin_not_allowed':\n case 'auth_not_enabled':\n case 'invalid_api_key':\n case 'environment_mismatch':\n // Merchant misconfiguration, not something the buyer can fix.\n return 'Accounts are not available right now. Please contact support.';\n case 'cart_already_claimed':\n return 'That cart belongs to a different account.';\n case 'not_found':\n return 'We could not find that.';\n case 'payment_method_in_use':\n // The server's message IS the fix (\"set another card as default\n // first\") — same rationale as weak_password below.\n return e.message || 'That payment method is in use and cannot be removed.';\n default:\n return 'Something went wrong. Please try again.';\n }\n}\n"],"mappings":";;;AAEA,SAAS,SAAS,SAA0B;AAC1C,MAAI,CAAC,WAAW,CAAC,OAAO,SAAS,OAAO,EAAG,QAAO;AAClD,MAAI,UAAU,GAAI,QAAO,eAAe,KAAK,KAAK,OAAO,CAAC;AAC1D,QAAM,UAAU,KAAK,KAAK,UAAU,EAAE;AACtC,SAAO,eAAe,OAAO,IAAI,YAAY,IAAI,WAAW,SAAS;AACvE;AAQO,SAAS,gBAAgB,KAAsB;AACpD,QAAM,IAAI;AACV,UAAQ,GAAG,MAAM;AAAA,IACf,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAEH,aAAO,EAAE,WAAW;AAAA,IACtB,KAAK;AACH,aAAO,sBAAsB,SAAS,EAAE,UAAU,CAAC;AAAA,IACrD,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAEH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAGH,aAAO,EAAE,WAAW;AAAA,IACtB;AACE,aAAO;AAAA,EACX;AACF;","names":[]}
@@ -0,0 +1,140 @@
1
+ "use client";
2
+ import {
3
+ ThrottleAuthError,
4
+ useAuth
5
+ } from "./chunk-SZ427BQ3.js";
6
+
7
+ // src/react/hooks.ts
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ function asError(err) {
10
+ if (err instanceof ThrottleAuthError) return err;
11
+ const anyErr = err;
12
+ return new ThrottleAuthError({
13
+ code: anyErr?.code ?? "request_failed",
14
+ message: anyErr?.message ?? String(err),
15
+ statusCode: anyErr?.statusCode ?? 0
16
+ });
17
+ }
18
+ function useResource(empty, load, deps) {
19
+ const { status, emailVerified } = useAuth();
20
+ const [data, setData] = useState(empty);
21
+ const [isLoading, setIsLoading] = useState(status === "loading");
22
+ const [error, setError] = useState(null);
23
+ const gen = useRef(0);
24
+ const alive = useRef(true);
25
+ useEffect(() => {
26
+ alive.current = true;
27
+ return () => {
28
+ alive.current = false;
29
+ gen.current++;
30
+ };
31
+ }, []);
32
+ const refetch = useCallback(async () => {
33
+ const mine = ++gen.current;
34
+ setIsLoading(true);
35
+ try {
36
+ const next = await load();
37
+ if (!alive.current || gen.current !== mine) return;
38
+ setData(next);
39
+ setError(null);
40
+ } catch (err) {
41
+ if (!alive.current || gen.current !== mine) return;
42
+ setError(asError(err));
43
+ } finally {
44
+ if (alive.current && gen.current === mine) setIsLoading(false);
45
+ }
46
+ }, deps);
47
+ useEffect(() => {
48
+ if (status !== "authenticated") {
49
+ gen.current++;
50
+ setData(empty);
51
+ setError(null);
52
+ setIsLoading(status === "loading");
53
+ return;
54
+ }
55
+ void refetch();
56
+ }, [status, emailVerified, refetch]);
57
+ return { data, isLoading, error, refetch };
58
+ }
59
+ function useCustomer() {
60
+ const { account } = useAuth();
61
+ const res = useResource(null, () => account.getProfile(), [account]);
62
+ const update = useCallback(async (patch) => {
63
+ await account.updateProfile(patch);
64
+ await res.refetch();
65
+ }, [account, res]);
66
+ return { ...res, update };
67
+ }
68
+ function useAddresses() {
69
+ const { account } = useAuth();
70
+ const res = useResource([], () => account.listAddresses(), [account]);
71
+ const after = async (p) => {
72
+ await p;
73
+ await res.refetch();
74
+ };
75
+ return {
76
+ ...res,
77
+ create: (input) => after(account.createAddress(input)),
78
+ update: (id, patch) => after(account.updateAddress(id, patch)),
79
+ remove: (id) => after(account.deleteAddress(id))
80
+ };
81
+ }
82
+ function useSavedCards() {
83
+ const { account } = useAuth();
84
+ const res = useResource([], () => account.listPaymentMethods(), [account]);
85
+ const after = async (p) => {
86
+ await p;
87
+ await res.refetch();
88
+ };
89
+ return {
90
+ ...res,
91
+ setDefault: (id) => after(account.setDefaultPaymentMethod(id)),
92
+ remove: (id) => after(account.deletePaymentMethod(id))
93
+ };
94
+ }
95
+ function useOrders(page) {
96
+ const { account } = useAuth();
97
+ return useResource([], () => account.listOrders(page), [account, page?.cursor, page?.limit]);
98
+ }
99
+ function useInvoices(page) {
100
+ const { account } = useAuth();
101
+ return useResource([], () => account.listInvoices(page), [account, page?.cursor, page?.limit]);
102
+ }
103
+ function useSubscriptions(page) {
104
+ const { account } = useAuth();
105
+ const res = useResource(
106
+ [],
107
+ () => account.listSubscriptions(page),
108
+ [account, page?.cursor, page?.limit]
109
+ );
110
+ const after = async (p) => {
111
+ await p;
112
+ await res.refetch();
113
+ };
114
+ return {
115
+ ...res,
116
+ cancel: (id, opts) => after(account.cancelSubscription(id, opts)),
117
+ pause: (id) => after(account.pauseSubscription(id)),
118
+ resume: (id) => after(account.resumeSubscription(id))
119
+ };
120
+ }
121
+ function useOrder(id) {
122
+ const { account } = useAuth();
123
+ return useResource(null, async () => id ? account.getOrder(id) : null, [account, id]);
124
+ }
125
+ function useInvoice(id) {
126
+ const { account } = useAuth();
127
+ return useResource(null, async () => id ? account.getInvoice(id) : null, [account, id]);
128
+ }
129
+
130
+ export {
131
+ useCustomer,
132
+ useAddresses,
133
+ useSavedCards,
134
+ useOrders,
135
+ useInvoices,
136
+ useSubscriptions,
137
+ useOrder,
138
+ useInvoice
139
+ };
140
+ //# sourceMappingURL=chunk-HGBAIM6B.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/hooks.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { ThrottleAuthError } from '../errors';\nimport type { Address, AddressInput, Customer, Invoice, Order, OrderDetail, PaymentMethod, ProfilePatch, Subscription } from '../types';\nimport type { Page } from '../account';\nimport { useAuth } from './context';\n\nexport interface Resource<T> {\n data: T;\n isLoading: boolean;\n error: ThrottleAuthError | null;\n refetch(): Promise<void>;\n}\n\nfunction asError(err: unknown): ThrottleAuthError {\n if (err instanceof ThrottleAuthError) return err;\n const anyErr = err as any;\n return new ThrottleAuthError({\n code: anyErr?.code ?? 'request_failed',\n message: anyErr?.message ?? String(err),\n statusCode: anyErr?.statusCode ?? 0,\n });\n}\n\n/**\n * One fetch-on-auth-change primitive behind every resource hook. `emailVerified`\n * is a dependency on purpose: verifying lifts the claim cutoff, and lists that\n * were legitimately empty a moment ago are not empty any more.\n */\nfunction useResource<T>(empty: T, load: () => Promise<T>, deps: unknown[]): Resource<T> {\n const { status, emailVerified } = useAuth();\n const [data, setData] = useState<T>(empty);\n const [isLoading, setIsLoading] = useState(status === 'loading');\n const [error, setError] = useState<ThrottleAuthError | null>(null);\n const gen = useRef(0);\n const alive = useRef(true);\n\n useEffect(() => {\n // Set on mount, not only cleared on unmount: React StrictMode mounts,\n // unmounts and remounts in development, and a ref that is only ever set\n // to false would silently discard every response for the rest of the\n // component's life.\n alive.current = true;\n return () => { alive.current = false; gen.current++; };\n }, []);\n\n const refetch = useCallback(async () => {\n const mine = ++gen.current;\n setIsLoading(true);\n try {\n const next = await load();\n if (!alive.current || gen.current !== mine) return;\n setData(next);\n setError(null);\n } catch (err) {\n if (!alive.current || gen.current !== mine) return;\n setError(asError(err));\n } finally {\n if (alive.current && gen.current === mine) setIsLoading(false);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps);\n\n useEffect(() => {\n if (status !== 'authenticated') {\n gen.current++; // abandon anything in flight\n setData(empty);\n setError(null);\n setIsLoading(status === 'loading');\n return;\n }\n void refetch();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [status, emailVerified, refetch]);\n\n return { data, isLoading, error, refetch };\n}\n\nexport function useCustomer(): Resource<Customer | null> & { update(patch: ProfilePatch): Promise<void> } {\n const { account } = useAuth();\n const res = useResource<Customer | null>(null, () => account.getProfile(), [account]);\n const update = useCallback(async (patch: ProfilePatch) => {\n await account.updateProfile(patch);\n await res.refetch();\n }, [account, res]);\n return { ...res, update };\n}\n\nexport function useAddresses(): Resource<Address[]> & {\n create(input: AddressInput): Promise<void>;\n update(id: string, patch: Partial<AddressInput>): Promise<void>;\n remove(id: string): Promise<void>;\n} {\n const { account } = useAuth();\n const res = useResource<Address[]>([], () => account.listAddresses(), [account]);\n const after = async (p: Promise<unknown>) => { await p; await res.refetch(); };\n return {\n ...res,\n create: (input) => after(account.createAddress(input)),\n update: (id, patch) => after(account.updateAddress(id, patch)),\n remove: (id) => after(account.deleteAddress(id)),\n };\n}\n\nexport function useSavedCards(): Resource<PaymentMethod[]> & {\n setDefault(id: string): Promise<void>;\n remove(id: string): Promise<void>;\n} {\n const { account } = useAuth();\n const res = useResource<PaymentMethod[]>([], () => account.listPaymentMethods(), [account]);\n const after = async (p: Promise<unknown>) => { await p; await res.refetch(); };\n return {\n ...res,\n setDefault: (id) => after(account.setDefaultPaymentMethod(id)),\n remove: (id) => after(account.deletePaymentMethod(id)),\n };\n}\n\nexport function useOrders(page?: Page): Resource<Order[]> {\n const { account } = useAuth();\n return useResource<Order[]>([], () => account.listOrders(page), [account, page?.cursor, page?.limit]);\n}\n\nexport function useInvoices(page?: Page): Resource<Invoice[]> {\n const { account } = useAuth();\n return useResource<Invoice[]>([], () => account.listInvoices(page), [account, page?.cursor, page?.limit]);\n}\n\nexport function useSubscriptions(page?: Page): Resource<Subscription[]> & {\n cancel(id: string, opts?: { atPeriodEnd?: boolean }): Promise<void>;\n pause(id: string): Promise<void>;\n resume(id: string): Promise<void>;\n} {\n const { account } = useAuth();\n const res = useResource<Subscription[]>([], () => account.listSubscriptions(page),\n [account, page?.cursor, page?.limit]);\n const after = async (p: Promise<unknown>) => { await p; await res.refetch(); };\n return {\n ...res,\n cancel: (id, opts) => after(account.cancelSubscription(id, opts)),\n pause: (id) => after(account.pauseSubscription(id)),\n resume: (id) => after(account.resumeSubscription(id)),\n };\n}\n\n/**\n * Single-resource reads for the detail views. A `null` id means \"nothing\n * selected\" and must not call the API — `useResource` still runs, so the\n * loader resolves to null rather than being skipped, which keeps the\n * loading/error shape identical whether or not an id is present.\n */\nexport function useOrder(id: string | null): Resource<OrderDetail | null> {\n const { account } = useAuth();\n return useResource<OrderDetail | null>(null, async () => (id ? account.getOrder(id) : null), [account, id]);\n}\n\nexport function useInvoice(id: string | null): Resource<Invoice | null> {\n const { account } = useAuth();\n return useResource<Invoice | null>(null, async () => (id ? account.getInvoice(id) : null), [account, id]);\n}\n"],"mappings":"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","names":[]}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  // src/errors.ts
2
4
  var ThrottleAuthError = class extends Error {
3
5
  code;
@@ -184,7 +186,8 @@ function createThrottleAuth(config) {
184
186
  const data = await request({ ...base, method: "POST", path: "/auth/refresh", body: { refreshToken } });
185
187
  await persist(data);
186
188
  } catch (err) {
187
- await forget();
189
+ const refused = err instanceof ThrottleAuthError && (err.statusCode === 401 || err.statusCode === 403);
190
+ if (refused) await forget();
188
191
  throw err;
189
192
  }
190
193
  });
@@ -365,6 +368,7 @@ function createAccount(auth) {
365
368
  getOrder: (id) => call("GET", `/me/orders/${seg(id)}`),
366
369
  listInvoices: (page) => call("GET", `/me/invoices${query(page)}`),
367
370
  getInvoice: (id) => call("GET", `/me/invoices/${seg(id)}`),
371
+ getInvoicePdfUrl: (id) => call("GET", `/me/invoices/${seg(id)}/pdf`),
368
372
  listSubscriptions: (page) => call("GET", `/me/subscriptions${query(page)}`),
369
373
  getSubscription: (id) => call("GET", `/me/subscriptions/${seg(id)}`),
370
374
  cancelSubscription: (id, opts) => call("POST", `/me/subscriptions/${seg(id)}/cancel`, opts),
@@ -374,11 +378,52 @@ function createAccount(auth) {
374
378
  };
375
379
  }
376
380
 
381
+ // src/react/context.tsx
382
+ import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
383
+ import { jsx } from "react/jsx-runtime";
384
+ var Ctx = createContext(null);
385
+ function ThrottleAuthProvider(props) {
386
+ const { auth: injected, publishableKey, baseUrl, storage, onStepUpRequired, children } = props;
387
+ const value = useMemo(() => {
388
+ const auth = injected ?? createThrottleAuth({ publishableKey, baseUrl, storage, onStepUpRequired });
389
+ return { auth, account: createAccount(auth) };
390
+ }, [injected, publishableKey, baseUrl]);
391
+ const restored = useRef(null);
392
+ useEffect(() => {
393
+ if (restored.current === value.auth) return;
394
+ restored.current = value.auth;
395
+ void value.auth.restore();
396
+ }, [value]);
397
+ return /* @__PURE__ */ jsx(Ctx.Provider, { value, children });
398
+ }
399
+ function useCtx() {
400
+ const ctx = useContext(Ctx);
401
+ if (!ctx) throw new Error("useAuth must be used inside a <ThrottleAuthProvider>");
402
+ return ctx;
403
+ }
404
+ function useAuthContext() {
405
+ return useCtx();
406
+ }
407
+ function useAuth() {
408
+ const { auth, account } = useCtx();
409
+ const snapshot = useSyncExternalStore(auth.subscribe, auth.getSnapshot, auth.getSnapshot);
410
+ return {
411
+ ...snapshot,
412
+ isLoading: snapshot.status === "loading",
413
+ isAuthenticated: snapshot.status === "authenticated",
414
+ auth,
415
+ account
416
+ };
417
+ }
418
+
377
419
  export {
378
420
  ThrottleAuthError,
379
421
  memoryStorage,
380
422
  localStorageAdapter,
381
423
  createThrottleAuth,
382
- createAccount
424
+ createAccount,
425
+ ThrottleAuthProvider,
426
+ useAuthContext,
427
+ useAuth
383
428
  };
384
- //# sourceMappingURL=chunk-SOPTBXF3.js.map
429
+ //# sourceMappingURL=chunk-SZ427BQ3.js.map