@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.
- package/package.json +1 -1
- package/src/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +19 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +110 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/safeRedirect.ts +41 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
package/package.json
CHANGED
|
@@ -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 = ({
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
() =>
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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();
|