@tribe-nest/forge 2.2.0 → 3.4.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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/client/createForgeClient.ts +85 -2
  3. package/src/client/tokenStorage.ts +31 -0
  4. package/src/contexts/AppAuthContext.tsx +100 -15
  5. package/src/contexts/PublicAuthContext.tsx +46 -9
  6. package/src/data/queries/useCheckouts.ts +84 -1
  7. package/src/data/queries/useCoachingAvailability.ts +18 -3
  8. package/src/data/queries/useCourseAccess.ts +1 -1
  9. package/src/data/queries/useCourses.ts +42 -1
  10. package/src/data/queries/useEvents.ts +15 -1
  11. package/src/data/queries/usePaymentFlow.ts +12 -0
  12. package/src/data/queries/useWebsite.ts +6 -0
  13. package/src/index.ts +19 -2
  14. package/src/provider/ForgeProvider.tsx +30 -1
  15. package/src/server/_tests/platformEvents.spec.ts +315 -0
  16. package/src/server/index.ts +17 -0
  17. package/src/server/jobs.ts +41 -10
  18. package/src/server/platform.ts +234 -9
  19. package/src/server/platformEvents.generated.ts +422 -0
  20. package/src/types/models.ts +110 -3
  21. package/src/ui/headless/auth/useSignupForm.ts +69 -4
  22. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  23. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  24. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  25. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  26. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  27. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  28. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  29. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  30. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  31. package/src/ui/headless/index.ts +15 -0
  32. package/src/ui/headless/work/useWorkPortal.ts +24 -21
  33. package/src/ui/index.ts +7 -0
  34. package/src/ui/shell/PoweredBy.tsx +60 -0
  35. package/src/ui/shell/TribeNestApp.tsx +15 -1
  36. package/src/ui/shell/shellGating.spec.ts +21 -1
  37. package/src/ui/shell/shellGating.ts +14 -0
  38. package/src/ui/styled/AddToCalendar.tsx +104 -0
  39. package/src/ui/styled/Checkout.tsx +45 -14
  40. package/src/ui/styled/CoachingBooking.tsx +28 -8
  41. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  42. package/src/ui/styled/CourseCheckout.tsx +49 -18
  43. package/src/ui/styled/DiscountCode.tsx +206 -0
  44. package/src/ui/styled/EventConfirmation.tsx +68 -22
  45. package/src/ui/styled/EventDetail.tsx +18 -5
  46. package/src/ui/styled/EventTickets.tsx +49 -5
  47. package/src/ui/styled/SignupForm.tsx +86 -35
  48. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  49. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  50. package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
  51. package/src/utils/_tests/safeRedirect.spec.ts +117 -0
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  53. package/src/utils/safeRedirect.ts +41 -0
  54. package/src/utils/ticketOrderOutcome.ts +125 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "2.2.0",
3
+ "version": "3.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,4 +1,21 @@
1
- import axios, { type AxiosInstance } from "axios";
1
+ import axios, { type AxiosError, type AxiosInstance } from "axios";
2
+
3
+ /**
4
+ * Access-token refresh (backend §7 cutover). Access tokens live ~15 minutes;
5
+ * when a request 401s the client exchanges the refresh token at `path` for a
6
+ * new pair and retries once. The refresh token ROTATES on every exchange, so
7
+ * `onRefreshed` must persist the new one.
8
+ */
9
+ export interface ForgeClientRefreshOptions {
10
+ /** Exchange endpoint for this lane, e.g. "/public/sessions/refresh". */
11
+ path: string;
12
+ /** The current refresh token, or null when anonymous (no refresh attempted). */
13
+ getRefreshToken: () => string | null;
14
+ /** Persist the rotated pair and update whatever backs `getToken`. */
15
+ onRefreshed: (tokens: { token: string; refreshToken: string }) => void;
16
+ /** The chain is dead (revoked/expired/reused) — clear stored tokens. */
17
+ onFailed: () => void;
18
+ }
2
19
 
3
20
  export interface ForgeClientOptions {
4
21
  /** Base URL of the TribeNest public API, e.g. https://api.tribenest.co */
@@ -13,6 +30,8 @@ export interface ForgeClientOptions {
13
30
  * place (Component 1) this is sent on every request to scope reads to the tenant.
14
31
  */
15
32
  publishableKey?: string;
33
+ /** Enable 401-triggered refresh-and-retry for this client's auth lane. */
34
+ refresh?: ForgeClientRefreshOptions;
16
35
  }
17
36
 
18
37
  /**
@@ -22,7 +41,12 @@ export interface ForgeClientOptions {
22
41
  * the base URL is injected (no `process.env`), the token is read per-request via a
23
42
  * closure, and `localStorage` access is SSR-guarded so the client is safe on Workers.
24
43
  */
25
- export const createForgeClient = ({ baseURL, getToken, publishableKey }: ForgeClientOptions): AxiosInstance => {
44
+ export const createForgeClient = ({
45
+ baseURL,
46
+ getToken,
47
+ publishableKey,
48
+ refresh,
49
+ }: ForgeClientOptions): AxiosInstance => {
26
50
  const client = axios.create({ baseURL });
27
51
 
28
52
  client.interceptors.request.use((config) => {
@@ -44,6 +68,65 @@ export const createForgeClient = ({ baseURL, getToken, publishableKey }: ForgeCl
44
68
  return config;
45
69
  });
46
70
 
71
+ if (refresh) {
72
+ // Single-flight: concurrent 401s (a page of queries expiring together) must
73
+ // produce ONE exchange — the refresh token rotates on use, so parallel
74
+ // exchanges would race each other through the server's grace window.
75
+ let inFlight: Promise<string | null> | null = null;
76
+
77
+ const exchange = (): Promise<string | null> => {
78
+ if (!inFlight) {
79
+ inFlight = (async () => {
80
+ const refreshToken = refresh.getRefreshToken();
81
+ if (!refreshToken) return null;
82
+ try {
83
+ // Bare axios on purpose — the shared client would recurse through
84
+ // this very interceptor.
85
+ const res = await axios.post(
86
+ `${baseURL}${refresh.path}`,
87
+ { refreshToken },
88
+ publishableKey ? { headers: { "x-forge-key": publishableKey } } : undefined,
89
+ );
90
+ const next = res.data as { token: string; refreshToken: string };
91
+ refresh.onRefreshed(next);
92
+ return next.token;
93
+ } catch {
94
+ refresh.onFailed();
95
+ return null;
96
+ } finally {
97
+ setTimeout(() => {
98
+ inFlight = null;
99
+ });
100
+ }
101
+ })();
102
+ }
103
+ return inFlight;
104
+ };
105
+
106
+ client.interceptors.response.use(
107
+ (response) => response,
108
+ async (error: AxiosError) => {
109
+ const config = error.config as (typeof error.config & { _retriedAfterRefresh?: boolean }) | undefined;
110
+ if (
111
+ error.response?.status === 401 &&
112
+ config &&
113
+ !config._retriedAfterRefresh &&
114
+ !config.url?.includes(refresh.path) &&
115
+ refresh.getRefreshToken()
116
+ ) {
117
+ const token = await exchange();
118
+ if (token) {
119
+ config._retriedAfterRefresh = true;
120
+ config.headers = config.headers ?? {};
121
+ config.headers["authorization"] = `Bearer ${token}`;
122
+ return client.request(config);
123
+ }
124
+ }
125
+ return Promise.reject(error);
126
+ },
127
+ );
128
+ }
129
+
47
130
  return client;
48
131
  };
49
132
 
@@ -0,0 +1,31 @@
1
+ // localStorage keys for the two auth lanes a Forge site can carry. They live
2
+ // here (not in the auth contexts) so the client factory and both contexts can
3
+ // share them without an import cycle. The fan/member and app-user lanes are
4
+ // deliberately separate pairs — an app-user session must never collide with a
5
+ // profile session on the same site.
6
+ //
7
+ // Access tokens are ~15 minutes now (backend §7 cutover); the refresh token is
8
+ // the long-lived credential and ROTATES on every exchange — always store the
9
+ // one that came back, never re-use the one you sent.
10
+
11
+ export const PUBLIC_ACCESS_TOKEN_KEY = "public-access-token";
12
+ export const PUBLIC_REFRESH_TOKEN_KEY = "public-refresh-token";
13
+
14
+ export const APP_ACCESS_TOKEN_KEY = "app-access-token";
15
+ export const APP_REFRESH_TOKEN_KEY = "app-refresh-token";
16
+
17
+ const canStore = () => typeof localStorage !== "undefined";
18
+
19
+ export const readStoredToken = (key: string): string | null => (canStore() ? localStorage.getItem(key) : null);
20
+
21
+ export const storeTokenPair = (accessKey: string, refreshKey: string, token: string, refreshToken?: string) => {
22
+ if (!canStore()) return;
23
+ localStorage.setItem(accessKey, token);
24
+ if (refreshToken) localStorage.setItem(refreshKey, refreshToken);
25
+ };
26
+
27
+ export const clearTokenPair = (accessKey: string, refreshKey: string) => {
28
+ if (!canStore()) return;
29
+ localStorage.removeItem(accessKey);
30
+ localStorage.removeItem(refreshKey);
31
+ };
@@ -2,6 +2,13 @@
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
3
  import { useForge } from "../provider/ForgeProvider";
4
4
  import { createForgeClient } from "../client/createForgeClient";
5
+ import {
6
+ APP_ACCESS_TOKEN_KEY as APP_ACCESS_KEY,
7
+ APP_REFRESH_TOKEN_KEY,
8
+ clearTokenPair,
9
+ readStoredToken,
10
+ storeTokenPair,
11
+ } from "../client/tokenStorage";
5
12
 
6
13
  // App-user auth for a mini-app frontend (account_associations type='app'). This
7
14
  // is the app's OWN user base — distinct from TribeNest fans (usePublicAuth) and
@@ -9,7 +16,8 @@ import { createForgeClient } from "../client/createForgeClient";
9
16
  // client (its own localStorage key) so an app-user session never collides with a
10
17
  // profile session on the same site. Backed by /public/app-sessions/*.
11
18
 
12
- export const APP_ACCESS_TOKEN_KEY = "app-access-token";
19
+ // Kept for compat — the canonical constant lives in client/tokenStorage.ts.
20
+ export const APP_ACCESS_TOKEN_KEY = APP_ACCESS_KEY;
13
21
 
14
22
  export interface AppAuthUser {
15
23
  id: string;
@@ -38,9 +46,19 @@ export interface AppSignupInput {
38
46
  lastName?: string;
39
47
  }
40
48
 
41
- export type AppSignupResult =
42
- | { status: "pending"; user: AppAuthUser }
43
- | { status: "active"; user: AppAuthUser };
49
+ /**
50
+ * What BOTH `signup` and `login` return: the platform emailed a 6-digit code and
51
+ * issued no session. Collect the code and call `verifyCode`. Neither entry point
52
+ * has a variant that logs a user straight in.
53
+ */
54
+ export type AppAuthChallenge = { status: "verify_email"; email: string };
55
+
56
+ /**
57
+ * The outcome of redeeming the code. `active` means signed in (token stored).
58
+ * `pending` means the address is proven but this app requires OWNER APPROVAL, so
59
+ * there is still no session — show a "waiting for approval" screen.
60
+ */
61
+ export type AppAuthResult = { status: "pending"; user: AppAuthUser } | { status: "active"; user: AppAuthUser };
44
62
 
45
63
  interface AppAuthContextType {
46
64
  appId?: string;
@@ -49,10 +67,26 @@ interface AppAuthContextType {
49
67
  isInitialized: boolean;
50
68
  isLoading: boolean;
51
69
  errorMessage: string | null;
52
- /** Sign up. When the app requires owner approval, returns status "pending" and
53
- * does NOT log the user in (no token) show a "pending approval" screen. */
54
- signup: (input: AppSignupInput) => Promise<AppSignupResult>;
55
- login: (input: { email: string; password: string }) => Promise<void>;
70
+ /**
71
+ * Step 1 of 2. Creates the account and emails a 6-digit code; it NEVER logs the
72
+ * user in. Always returns `{ status: "verify_email" }` — including for an email
73
+ * the platform has never seen, so the response can't be used to discover which
74
+ * addresses are already registered. Collect the code, then call `verifyCode`.
75
+ */
76
+ signup: (input: AppSignupInput) => Promise<AppAuthChallenge>;
77
+ /**
78
+ * Step 1 of 2. Checks the password and emails a 6-digit code — it does NOT
79
+ * return a session. Collect the code and call `verifyCode`. (App login is
80
+ * two-factor by design; a wrong password still fails here, before any code.)
81
+ */
82
+ login: (input: { email: string; password: string }) => Promise<AppAuthChallenge>;
83
+ /**
84
+ * Step 2 of 2 for BOTH signup and login. Redeems the emailed code. On success
85
+ * the user is signed in (`active`) unless this app requires owner approval, in
86
+ * which case the address is proven but the session still waits (`pending`) —
87
+ * proving your email is not the owner approving you.
88
+ */
89
+ verifyCode: (input: { email: string; code: string }) => Promise<AppAuthResult>;
56
90
  logout: () => Promise<void>;
57
91
  refetch: () => Promise<void>;
58
92
  clearError: () => void;
@@ -80,7 +114,26 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
80
114
  // client (which carries the profile/member token).
81
115
  const tokenRef = useRef<string | null>(null);
82
116
  const client = useMemo(
83
- () => createForgeClient({ baseURL: apiUrl, publishableKey, getToken: () => tokenRef.current }),
117
+ () =>
118
+ createForgeClient({
119
+ baseURL: apiUrl,
120
+ publishableKey,
121
+ getToken: () => tokenRef.current,
122
+ // App-user lane (backend §7): same 15-minute access tokens, own refresh
123
+ // chain on its own storage keys so it never collides with a fan session.
124
+ refresh: {
125
+ path: "/public/app-sessions/refresh",
126
+ getRefreshToken: () => readStoredToken(APP_REFRESH_TOKEN_KEY),
127
+ onRefreshed: ({ token: next, refreshToken }) => {
128
+ tokenRef.current = next;
129
+ storeTokenPair(APP_ACCESS_TOKEN_KEY, APP_REFRESH_TOKEN_KEY, next, refreshToken);
130
+ },
131
+ onFailed: () => {
132
+ tokenRef.current = null;
133
+ clearTokenPair(APP_ACCESS_TOKEN_KEY, APP_REFRESH_TOKEN_KEY);
134
+ },
135
+ },
136
+ }),
84
137
  [apiUrl, publishableKey],
85
138
  );
86
139
 
@@ -127,13 +180,41 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
127
180
  setErrorMessage(null);
128
181
  try {
129
182
  const res = await client.post("/public/app-sessions/signup", { appId, ...input });
130
- const { status, account, token } = res.data as { status: "pending" | "active"; account: AppAuthUser; token?: string };
183
+ const { email } = res.data as { status: "verify_email"; email: string };
184
+ // No token here, by design — see AppAuthChallenge.
185
+ return { status: "verify_email", email };
186
+ } catch (err) {
187
+ setErrorMessage(extractError(err));
188
+ throw err;
189
+ } finally {
190
+ setIsLoading(false);
191
+ }
192
+ },
193
+ [appId, client],
194
+ );
195
+
196
+ const verifyCode = useCallback<AppAuthContextType["verifyCode"]>(
197
+ async ({ email, code }) => {
198
+ if (!appId) throw new Error("AppAuthProvider: no appId in Forge context.");
199
+ setIsLoading(true);
200
+ setErrorMessage(null);
201
+ try {
202
+ const res = await client.post("/public/app-sessions/verify", { appId, email, code });
203
+ const { status, account, token, refreshToken } = res.data as {
204
+ status: "pending" | "active";
205
+ account: AppAuthUser;
206
+ token?: string;
207
+ refreshToken?: string;
208
+ };
131
209
  if (status === "active" && token) {
132
210
  setToken(token);
211
+ if (refreshToken && typeof localStorage !== "undefined") {
212
+ localStorage.setItem(APP_REFRESH_TOKEN_KEY, refreshToken);
213
+ }
133
214
  setUser(account);
134
215
  return { status: "active", user: account };
135
216
  }
136
- // Pending approval — no token issued.
217
+ // Email proven, but the owner still has to approve — no token issued.
137
218
  return { status: "pending", user: account };
138
219
  } catch (err) {
139
220
  setErrorMessage(extractError(err));
@@ -152,8 +233,10 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
152
233
  setErrorMessage(null);
153
234
  try {
154
235
  const res = await client.post("/public/app-sessions", { appId, ...input });
155
- setToken(res.data.token);
156
- setUser(res.data.account);
236
+ // The password was checked, but no session is issued until the emailed
237
+ // code is redeemed — see AppAuthChallenge.
238
+ const { email } = res.data as { status: "verify_email"; email: string };
239
+ return { status: "verify_email", email };
157
240
  } catch (err) {
158
241
  setErrorMessage(extractError(err));
159
242
  throw err;
@@ -161,7 +244,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
161
244
  setIsLoading(false);
162
245
  }
163
246
  },
164
- [appId, client, setToken],
247
+ [appId, client],
165
248
  );
166
249
 
167
250
  const logout = useCallback<AppAuthContextType["logout"]>(async () => {
@@ -171,6 +254,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
171
254
  // best-effort — clear locally regardless
172
255
  }
173
256
  setToken(null);
257
+ if (typeof localStorage !== "undefined") localStorage.removeItem(APP_REFRESH_TOKEN_KEY);
174
258
  setUser(null);
175
259
  }, [client, setToken]);
176
260
 
@@ -184,6 +268,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
184
268
  errorMessage,
185
269
  signup,
186
270
  login,
271
+ verifyCode,
187
272
  logout,
188
273
  refetch,
189
274
  clearError: () => setErrorMessage(null),
@@ -192,7 +277,7 @@ export function AppAuthProvider({ children }: { children: ReactNode }) {
192
277
  // crash in a render guard is worse than a hidden button.
193
278
  hasPermission: (permission: string) => (user?.permissions ?? []).includes(permission),
194
279
  }),
195
- [appId, user, isInitialized, isLoading, errorMessage, signup, login, logout, refetch],
280
+ [appId, user, isInitialized, isLoading, errorMessage, signup, login, verifyCode, logout, refetch],
196
281
  );
197
282
 
198
283
  return <AppAuthContext.Provider value={value}>{children}</AppAuthContext.Provider>;
@@ -23,8 +23,16 @@ enum Types {
23
23
  UpdateUser = "UPDATE_USER",
24
24
  UpdateErrorMessage = "UPDATE_ERROR_MESSAGE",
25
25
  }
26
- export const ACCESS_TOKEN_KEY = "public-access-token";
27
- export const PUBLIC_ACCESS_TOKEN_KEY = "public-access-token";
26
+ import {
27
+ PUBLIC_ACCESS_TOKEN_KEY,
28
+ PUBLIC_REFRESH_TOKEN_KEY,
29
+ clearTokenPair,
30
+ storeTokenPair,
31
+ } from "../client/tokenStorage";
32
+
33
+ // Kept for compat — the canonical constants live in client/tokenStorage.ts.
34
+ export const ACCESS_TOKEN_KEY = PUBLIC_ACCESS_TOKEN_KEY;
35
+ export { PUBLIC_ACCESS_TOKEN_KEY };
28
36
 
29
37
  type AuthPayload = {
30
38
  [Types.Initial]: { isAuthenticated: boolean; user: PublicAuthUser | null };
@@ -198,8 +206,8 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
198
206
 
199
207
  try {
200
208
  const response = await client.post("/public/sessions", data);
201
- const { token, account, smartLinkPath } = response.data;
202
- localStorage.setItem(ACCESS_TOKEN_KEY, token);
209
+ const { token, refreshToken, account, smartLinkPath } = response.data;
210
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
203
211
  setToken(token);
204
212
  dispatch({ type: Types.Login, payload: { user: account } });
205
213
  return { smartLinkPath };
@@ -220,8 +228,8 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
220
228
 
221
229
  try {
222
230
  const response = await client.post("/public/sessions/verify", { email, code, profileId });
223
- const { token, account, smartLinkPath } = response.data;
224
- localStorage.setItem(ACCESS_TOKEN_KEY, token);
231
+ const { token, refreshToken, account, smartLinkPath } = response.data;
232
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
225
233
  setToken(token);
226
234
  dispatch({ type: Types.Login, payload: { user: account } });
227
235
  return { smartLinkPath };
@@ -264,8 +272,36 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
264
272
  const response = await client.post("/public/accounts", {
265
273
  ...data,
266
274
  });
267
- const { token, account, smartLinkPath } = response.data;
268
- localStorage.setItem(ACCESS_TOKEN_KEY, token);
275
+ // No token: signup issues no session until the emailed code is redeemed
276
+ // (see PublicAuthChallenge). Keep the caller's data — `verifyRegistration`
277
+ // needs the coupon/consent flags again to finish the job.
278
+ const { email } = response.data as { status: "verify_email"; email: string };
279
+ dispatch({ type: Types.Loading, payload: { isLoading: false } });
280
+ return { status: "verify_email" as const, email };
281
+ } catch (err: unknown) {
282
+ const error = err as ApiError;
283
+ dispatch({ type: Types.Loading, payload: { isLoading: false } });
284
+ throw error;
285
+ }
286
+ };
287
+
288
+ const verifyRegistration = async (data: {
289
+ email: string;
290
+ code: string;
291
+ profileId?: string;
292
+ couponCode?: string;
293
+ acceptedTerms?: boolean;
294
+ acceptedPrivacy?: boolean;
295
+ }) => {
296
+ dispatch({ type: Types.Loading, payload: { isLoading: true } });
297
+
298
+ try {
299
+ const response = await client.post("/public/accounts/verify", {
300
+ ...data,
301
+ profileId: data.profileId ?? profileId,
302
+ });
303
+ const { token, refreshToken, account, smartLinkPath } = response.data;
304
+ storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token, refreshToken);
269
305
  setToken(token);
270
306
 
271
307
  dispatch({ type: Types.Register, payload: { user: account } });
@@ -279,7 +315,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
279
315
 
280
316
  const logout = async (persist = true) => {
281
317
  try {
282
- localStorage.removeItem(ACCESS_TOKEN_KEY);
318
+ clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
283
319
  dispatch({ type: Types.Logout });
284
320
  if (persist) await client.delete("/sessions");
285
321
  } catch (error) {
@@ -308,6 +344,7 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
308
344
  resendLoginCode,
309
345
  logout,
310
346
  register,
347
+ verifyRegistration,
311
348
  updateLocalUser,
312
349
  clearErrorMessage,
313
350
  refetchUser,
@@ -1,5 +1,6 @@
1
1
  import { useMutation } from "@tanstack/react-query";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
+ import { bundleCouponRequestBody } from "../../ui/headless/checkout/bundleCoupon";
3
4
  import type { CartItem, TicketCartItem } from "../../contexts/CartContext";
4
5
 
5
6
  /** One line of a bundle, in the shape `POST /public/checkouts` expects. */
@@ -69,7 +70,35 @@ export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCa
69
70
  return [...ticketLines, ...productLines];
70
71
  }
71
72
 
72
- export type CreateCheckoutResult = { checkoutId: string; currency: string; subtotalCents: number };
73
+ /**
74
+ * One discount that actually came off a bundle — entered or automatic.
75
+ *
76
+ * `discountAmount` is in MAJOR units, unlike everything else the bundle endpoint
77
+ * returns, because it is the same shape every other pillar's checkout answers
78
+ * with. Naming the coupon is what lets an AUTOMATIC bundle discount render as
79
+ * "SUMMER10 — $6.00 off" rather than an unexplained reduction; before the
80
+ * response carried this, only `discountCents` and a `couponId` came back and
81
+ * there is no public `couponId` → code lookup.
82
+ */
83
+ export type AppliedBundleCoupon = {
84
+ code: string;
85
+ discountKind: string;
86
+ /** MAJOR units. */
87
+ discountAmount: number;
88
+ };
89
+
90
+ /** Note the units: the bundle endpoint is the only checkout answering in MINOR units. */
91
+ export type CreateCheckoutResult = {
92
+ checkoutId: string;
93
+ currency: string;
94
+ /** GROSS, minor units. (Beware: `start-payment` returns a NET `subtotalCents`.) */
95
+ subtotalCents: number;
96
+ discountCents?: number;
97
+ couponId?: string | null;
98
+ /** NET = max(0, subtotalCents − discountCents), minor units. */
99
+ totalCents?: number;
100
+ appliedCoupons?: AppliedBundleCoupon[];
101
+ };
73
102
 
74
103
  export function useCreateCheckout() {
75
104
  const { client, profileId } = useForge();
@@ -82,6 +111,60 @@ export function useCreateCheckout() {
82
111
  });
83
112
  }
84
113
 
114
+ /**
115
+ * What `POST /public/checkouts/apply-coupon` answers with.
116
+ *
117
+ * The payment fields are populated only when the bundle had already started
118
+ * payment: applying re-prices the children AND re-mints the intent, so the
119
+ * caller swaps its payment element onto the new secret rather than re-running
120
+ * start-payment. An empty `paymentSecret` means there was no intent to replace.
121
+ */
122
+ export type ApplyCheckoutCouponResult = {
123
+ checkoutId: string;
124
+ currency: string;
125
+ /** GROSS, minor units — what the goods cost undiscounted. */
126
+ subtotalCents: number;
127
+ discountCents: number;
128
+ couponId: string | null;
129
+ /** NET = max(0, subtotalCents − discountCents), minor units. */
130
+ totalCents: number;
131
+ appliedCoupons: AppliedBundleCoupon[];
132
+ paymentSecret: string;
133
+ paymentId: string;
134
+ chargedAmount: number;
135
+ chargedCurrency: string;
136
+ /** True when the re-price took the bundle to zero — it is already settled. */
137
+ isFreeCheckout: boolean;
138
+ };
139
+
140
+ /**
141
+ * Apply — or REMOVE — a discount code on a bundle that already exists.
142
+ *
143
+ * Omitting `couponCode` removes whatever is on it and restores the original
144
+ * total. This is the bundle's counterpart to `/public/orders/apply-coupon`, and
145
+ * it is why the code field on a bundle no longer has to freeze the moment the
146
+ * checkout is created.
147
+ */
148
+ export function useApplyCheckoutCoupon() {
149
+ const { client, profileId } = useForge();
150
+
151
+ return useMutation<
152
+ ApplyCheckoutCouponResult,
153
+ unknown,
154
+ { checkoutId: string; returnUrl: string; couponCode?: string }
155
+ >({
156
+ mutationFn: async ({ checkoutId, returnUrl, couponCode }) => {
157
+ // The body is built by `bundleCouponRequestBody` so the "remove sends no
158
+ // code at all" rule is asserted in one place rather than trusted here.
159
+ const res = await client.post(
160
+ "/public/checkouts/apply-coupon",
161
+ bundleCouponRequestBody({ profileId, checkoutId, returnUrl, couponCode }),
162
+ );
163
+ return res.data;
164
+ },
165
+ });
166
+ }
167
+
85
168
  export type FinalizeCheckoutResult = {
86
169
  checkoutId: string;
87
170
  status: string;
@@ -1,4 +1,4 @@
1
- import type { BookingSlot } from "../../types/models";
1
+ import type { BookingSlot, PillarDiscountQuote } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -40,13 +40,28 @@ export type UpdateCoachingBookingInput = {
40
40
  confirmIfFree?: boolean;
41
41
  questionnaire?: unknown;
42
42
  attributionRefId?: string;
43
+ /** Omit (or send `undefined`) to CLEAR a previously applied code. */
44
+ couponCode?: string;
43
45
  };
44
46
 
45
- /** Attach buyer details to a reserved coaching booking (confirms it if free). */
47
+ export type UpdateCoachingBookingResult = PillarDiscountQuote & {
48
+ bookingId: string;
49
+ isConfirmed: boolean;
50
+ };
51
+
52
+ /**
53
+ * Attach buyer details to a reserved coaching booking (confirms it if free) and
54
+ * (re-)price it.
55
+ *
56
+ * This doubles as the coaching pillar's quote: the gross price is re-derived
57
+ * from the PRODUCT on every call, so applying and then removing a discount code
58
+ * returns the buyer to exactly the original total instead of compounding off an
59
+ * already-netted figure. Only a still-`reserved` booking is re-priced.
60
+ */
46
61
  export function useUpdateCoachingBooking(productId?: string) {
47
62
  const { client } = useForge();
48
63
 
49
- return useMutation<{ bookingId: string; isConfirmed: boolean }, unknown, UpdateCoachingBookingInput>({
64
+ return useMutation<UpdateCoachingBookingResult, unknown, UpdateCoachingBookingInput>({
50
65
  mutationFn: async (body) => {
51
66
  const res = await client.post(`/public/coaching/products/${productId}/booking/update`, body);
52
67
  return res.data;
@@ -38,7 +38,7 @@ export function useClaimCourseAccess() {
38
38
  const { client } = useForge();
39
39
 
40
40
  return useMutation<
41
- { token?: string; accessId?: string },
41
+ { token?: string; refreshToken?: string; accessId?: string },
42
42
  unknown,
43
43
  { token: string; password: string; firstName: string; lastName: string }
44
44
  >({
@@ -1,4 +1,4 @@
1
- import type { PaginatedData, PublicCourse } from "../../types/models";
1
+ import type { PaginatedData, PillarDiscountQuote, PublicCourse } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -10,6 +10,47 @@ export type CreateCourseBookingInput = {
10
10
  attributionRefId?: string;
11
11
  };
12
12
 
13
+ export type UpdateCourseBookingInput = {
14
+ bookingId: string;
15
+ email: string;
16
+ firstName: string;
17
+ lastName: string;
18
+ /** Confirm the booking outright when nothing is left to charge. */
19
+ confirmIfFree?: boolean;
20
+ questionnaire?: unknown;
21
+ attributionRefId?: string;
22
+ /** Omit (or send `undefined`) to CLEAR a previously applied code. */
23
+ couponCode?: string;
24
+ };
25
+
26
+ export type UpdateCourseBookingResult = PillarDiscountQuote & {
27
+ bookingId: string;
28
+ isConfirmed: boolean;
29
+ };
30
+
31
+ /**
32
+ * Attach buyer details to a course booking and (re-)price it.
33
+ *
34
+ * This is the course pillar's de-facto quote: it re-derives the gross price
35
+ * from the COURSE on every call — never from the booking's already-netted total
36
+ * — specifically so it can be fired repeatedly as the buyer edits the form.
37
+ * That is what makes apply/remove of a discount code safe here, and why
38
+ * removing one restores the original total rather than compounding.
39
+ *
40
+ * Only a still-`reserved` booking is re-priced; a confirmed one keeps the
41
+ * coupon and total it was actually charged at.
42
+ */
43
+ export function useUpdateCourseBooking(courseId?: string) {
44
+ const { client, profileId } = useForge();
45
+
46
+ return useMutation<UpdateCourseBookingResult, unknown, UpdateCourseBookingInput>({
47
+ mutationFn: async (body) => {
48
+ const res = await client.post(`/public/courses/${courseId}/booking/update`, { profileId, ...body });
49
+ return res.data;
50
+ },
51
+ });
52
+ }
53
+
13
54
  /** Create a course booking (buyer details → bookingId; free courses skip payment). */
14
55
  export function useCreateCourseBooking(courseId?: string) {
15
56
  const { client, profileId } = useForge();