@tribe-nest/forge 3.14.0 → 3.19.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/contexts/CartContext.tsx +25 -1
- package/src/data/queries/useCheckouts.ts +14 -0
- package/src/data/queries/useCoachingAvailability.ts +13 -0
- package/src/data/queries/useCourseAccess.ts +81 -0
- package/src/data/queries/useCourses.ts +19 -1
- package/src/data/queries/useEvents.ts +6 -0
- package/src/data/queries/useFinalize.ts +24 -4
- package/src/index.ts +7 -0
- package/src/types/models.ts +41 -1
- package/src/ui/format/_tests/pwyw.spec.ts +157 -0
- package/src/ui/format/pwyw.ts +95 -0
- package/src/ui/headless/booking/useBookingSecret.ts +41 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
- package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
- package/src/ui/headless/course/useCourseCheckout.ts +18 -1
- package/src/ui/headless/course/useCourseClassroom.ts +242 -0
- package/src/ui/headless/event/useEventCheckout.ts +96 -6
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +17 -0
- package/src/ui/styled/AccountDashboard.tsx +7 -0
- package/src/ui/styled/Cart.tsx +11 -8
- package/src/ui/styled/CartLineOptions.tsx +107 -0
- package/src/ui/styled/Checkout.tsx +5 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
- package/src/ui/styled/CoachingBooking.tsx +9 -1
- package/src/ui/styled/CoachingConfirmation.tsx +44 -3
- package/src/ui/styled/Confirmation.tsx +94 -0
- package/src/ui/styled/CourseAccess.tsx +191 -51
- package/src/ui/styled/CourseCheckout.tsx +9 -1
- package/src/ui/styled/CourseConfirmation.tsx +42 -3
- package/src/ui/styled/EventTickets.tsx +114 -0
- package/src/ui/styled/InvoicePayment.tsx +65 -11
- package/src/ui/styled/ProductDetail.tsx +8 -0
- package/src/utils/_tests/bookingSecret.spec.ts +115 -0
- package/src/utils/bookingSecret.ts +100 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { readBookingSecret } from "../../../utils/bookingSecret";
|
|
3
|
+
|
|
4
|
+
export interface BookingSecretLookup {
|
|
5
|
+
/** The stored credential, or null when nothing is held for this booking. */
|
|
6
|
+
secret: string | null;
|
|
7
|
+
/** False until storage has actually been consulted (first client effect). */
|
|
8
|
+
resolved: boolean;
|
|
9
|
+
/** Storage was consulted and held nothing. Only meaningful once `resolved`. */
|
|
10
|
+
isMissing: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read back the per-booking credential a checkout stashed before handing the
|
|
15
|
+
* buyer to the payment provider. Used by the confirmation pages, which are
|
|
16
|
+
* reached by a redirect and therefore start with no in-memory state at all.
|
|
17
|
+
*
|
|
18
|
+
* The read happens in an EFFECT rather than during render on purpose: these
|
|
19
|
+
* pages are server-rendered, `sessionStorage` does not exist on the server, and
|
|
20
|
+
* reading it inline would make the server and the first client render disagree.
|
|
21
|
+
* `resolved` lets the caller hold the finalise call until the real answer is in,
|
|
22
|
+
* so a booking is never finalised without a secret it actually has.
|
|
23
|
+
*
|
|
24
|
+
* A `null` secret is NOT proof the call will fail: bookings created before the
|
|
25
|
+
* credential existed have no stored hash server-side and still finalise on the
|
|
26
|
+
* id alone. So callers should attempt the finalise regardless and use
|
|
27
|
+
* `isMissing` only to EXPLAIN a failure — see `CourseConfirmation` /
|
|
28
|
+
* `CoachingConfirmation`.
|
|
29
|
+
*/
|
|
30
|
+
export function useBookingSecret(bookingId?: string | null): BookingSecretLookup {
|
|
31
|
+
const [state, setState] = useState<{ secret: string | null; resolved: boolean }>({
|
|
32
|
+
secret: null,
|
|
33
|
+
resolved: false,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setState({ secret: readBookingSecret(bookingId), resolved: true });
|
|
38
|
+
}, [bookingId]);
|
|
39
|
+
|
|
40
|
+
return { ...state, isMissing: state.resolved && !state.secret };
|
|
41
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
2
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
3
|
import { useGetCoachingProduct } from "../../../data/queries/useCoachingProducts";
|
|
4
4
|
import {
|
|
@@ -11,6 +11,7 @@ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
|
11
11
|
import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
|
|
12
12
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
13
13
|
import { readLanding } from "../../../utils/landing";
|
|
14
|
+
import { forgetBookingSecret, rememberBookingSecret } from "../../../utils/bookingSecret";
|
|
14
15
|
import type { CancellationTermsView } from "../../../types/models";
|
|
15
16
|
|
|
16
17
|
export type CoachingBookingStep = "slot" | "details" | "payment";
|
|
@@ -82,6 +83,13 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
82
83
|
|
|
83
84
|
const [step, setStep] = useState<CoachingBookingStep>("slot");
|
|
84
85
|
const [bookingId, setBookingId] = useState<string | null>(null);
|
|
86
|
+
/**
|
|
87
|
+
* The per-booking credential returned by `reserve`. Held in a ref (not state)
|
|
88
|
+
* so it never triggers a render and never lands in a dependency array, and
|
|
89
|
+
* mirrored into sessionStorage for the leg after the payment redirect. The
|
|
90
|
+
* backend returns it once and cannot reissue it.
|
|
91
|
+
*/
|
|
92
|
+
const bookingSecretRef = useRef<string | undefined>(undefined);
|
|
85
93
|
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
|
|
86
94
|
const [firstName, setFirstName] = useState(user?.firstName ?? "");
|
|
87
95
|
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
@@ -119,6 +127,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
119
127
|
}
|
|
120
128
|
return update.mutateAsync({
|
|
121
129
|
bookingId,
|
|
130
|
+
bookingSecret: bookingSecretRef.current,
|
|
122
131
|
email,
|
|
123
132
|
firstName,
|
|
124
133
|
lastName,
|
|
@@ -158,9 +167,17 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
158
167
|
}
|
|
159
168
|
try {
|
|
160
169
|
// Release any previously-held slot before reserving a new one.
|
|
161
|
-
if (bookingId)
|
|
170
|
+
if (bookingId) {
|
|
171
|
+
await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
|
|
172
|
+
forgetBookingSecret(bookingId);
|
|
173
|
+
}
|
|
162
174
|
const data = await reserve.mutateAsync({ slotId: selectedSlotId });
|
|
163
175
|
setBookingId(data.bookingId);
|
|
176
|
+
bookingSecretRef.current = data.bookingSecret;
|
|
177
|
+
// Written the instant it arrives: this is the only time the server will
|
|
178
|
+
// ever hand it over, and the finalise page (post-redirect) has no other
|
|
179
|
+
// way to get it.
|
|
180
|
+
rememberBookingSecret(data.bookingId, data.bookingSecret);
|
|
164
181
|
setAgreedCancellation(data.cancellation ?? null);
|
|
165
182
|
setReservationExpiresAt(Date.now() + RESERVATION_HOLD_MS);
|
|
166
183
|
setStep("details");
|
|
@@ -177,6 +194,11 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
177
194
|
*/
|
|
178
195
|
const releaseReservation = useCallback(async () => {
|
|
179
196
|
if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
|
|
197
|
+
// The credential belonged to a booking that no longer exists — leaving it
|
|
198
|
+
// in storage would strand a dead secret in the tab for the rest of the
|
|
199
|
+
// session.
|
|
200
|
+
forgetBookingSecret(bookingId);
|
|
201
|
+
bookingSecretRef.current = undefined;
|
|
180
202
|
setBookingId(null);
|
|
181
203
|
setSelectedSlotId(null);
|
|
182
204
|
setReservationExpiresAt(null);
|
|
@@ -206,6 +228,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
206
228
|
try {
|
|
207
229
|
const updated = await update.mutateAsync({
|
|
208
230
|
bookingId,
|
|
231
|
+
bookingSecret: bookingSecretRef.current,
|
|
209
232
|
email,
|
|
210
233
|
firstName,
|
|
211
234
|
lastName,
|
|
@@ -227,7 +250,7 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
227
250
|
else if (typeof window !== "undefined") window.location.href = ru;
|
|
228
251
|
return;
|
|
229
252
|
}
|
|
230
|
-
const result = await flow.start({ bookingId, returnUrl: ru });
|
|
253
|
+
const result = await flow.start({ bookingId, bookingSecret: bookingSecretRef.current, returnUrl: ru });
|
|
231
254
|
if (result.provider && result.provider !== "stripe") return;
|
|
232
255
|
setStep("payment");
|
|
233
256
|
} catch (e) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { courseAccessDenial, isLegacyCourseAccess } from "../../../../data/queries/useCourseAccess";
|
|
3
|
+
import { buildCourseAccessGate, buildCourseAccessSecurePrompt, courseSignInHref } from "../useCourseClassroom";
|
|
4
|
+
import type { CourseAccessData } from "../../../../types/models";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Account-bound course access — the decisions, without a DOM or a session.
|
|
8
|
+
*
|
|
9
|
+
* The whole feature is a two-way branch on a status code, and BOTH wrong
|
|
10
|
+
* answers strand a real person: a 403 drawn as "sign in" loops someone who is
|
|
11
|
+
* already signed in, and a 401 drawn as "wrong account" tells a buyer with no
|
|
12
|
+
* session that their own enrolment is somebody else's. So the mapping is
|
|
13
|
+
* pinned here rather than left to the component.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const err = (status: number, message?: string) => ({
|
|
17
|
+
response: { status, ...(message ? { data: { message } } : {}) },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const accessData = (accountId: string | null, email = "buyer@example.com"): CourseAccessData =>
|
|
21
|
+
({
|
|
22
|
+
access: { id: "a1", courseId: "c1", email, firstName: null, lastName: null, currentLessonId: null, accountId },
|
|
23
|
+
course: { id: "c1", title: "Course", description: "", modules: [] },
|
|
24
|
+
progress: [],
|
|
25
|
+
}) as unknown as CourseAccessData;
|
|
26
|
+
|
|
27
|
+
describe("courseAccessDenial", () => {
|
|
28
|
+
it("splits the two account-binding refusals and nothing else", () => {
|
|
29
|
+
expect(courseAccessDenial(err(401))).toMatchObject({ reason: "account_required", status: 401 });
|
|
30
|
+
expect(courseAccessDenial(err(403))).toMatchObject({ reason: "wrong_account", status: 403 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("carries the API's own sentence when it sent one", () => {
|
|
34
|
+
expect(courseAccessDenial(err(401, "Sign in to open this course"))?.message).toBe("Sign in to open this course");
|
|
35
|
+
expect(courseAccessDenial(err(403))?.message).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is null for every failure that is NOT about account binding", () => {
|
|
39
|
+
// A 404 must keep its old rendering; a network error has no `response` at
|
|
40
|
+
// all. Total over unknown so no caller has to narrow first.
|
|
41
|
+
for (const other of [err(404), err(500), new Error("offline"), undefined, null, "nope", {}]) {
|
|
42
|
+
expect(courseAccessDenial(other)).toBeNull();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isLegacyCourseAccess", () => {
|
|
48
|
+
it("is true only for a grant bound to nobody", () => {
|
|
49
|
+
expect(isLegacyCourseAccess(accessData(null))).toBe(true);
|
|
50
|
+
expect(isLegacyCourseAccess(accessData("acc_1"))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("treats an absent field as legacy — an older API sends no accountId at all", () => {
|
|
54
|
+
const stale = { access: { email: "x@y.com" }, course: {}, progress: [] } as unknown as CourseAccessData;
|
|
55
|
+
expect(isLegacyCourseAccess(stale)).toBe(true);
|
|
56
|
+
expect(isLegacyCourseAccess(undefined)).toBe(false);
|
|
57
|
+
expect(isLegacyCourseAccess(null)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("buildCourseAccessGate", () => {
|
|
62
|
+
it("401 offers a sign-in that returns to this exact page", () => {
|
|
63
|
+
const gate = buildCourseAccessGate({
|
|
64
|
+
denial: courseAccessDenial(err(401)),
|
|
65
|
+
isError: true,
|
|
66
|
+
loginPath: "/i/login",
|
|
67
|
+
currentPath: "/i/course-access/abc?x=1",
|
|
68
|
+
});
|
|
69
|
+
expect(gate?.kind).toBe("sign_in_required");
|
|
70
|
+
expect(gate?.actionHref).toBe(`/i/login?redirect=${encodeURIComponent("/i/course-access/abc?x=1")}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("403 never says 'sign in', and its action is a sign-OUT, not a link", () => {
|
|
74
|
+
const gate = buildCourseAccessGate({ denial: courseAccessDenial(err(403)), isError: true });
|
|
75
|
+
expect(gate?.kind).toBe("wrong_account");
|
|
76
|
+
// The failure this guards: a signed-in reader told to do the one thing they
|
|
77
|
+
// have already done.
|
|
78
|
+
expect(`${gate?.title} ${gate?.body} ${gate?.actionLabel}`.toLowerCase()).not.toContain("sign in ");
|
|
79
|
+
expect(gate?.actionHref).toBeNull();
|
|
80
|
+
expect(gate?.actionLabel).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("falls back to the old not-found panel for any other failure", () => {
|
|
84
|
+
expect(buildCourseAccessGate({ denial: null, isError: true })?.kind).toBe("not_found");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is null when the course loaded — the player draws instead", () => {
|
|
88
|
+
expect(buildCourseAccessGate({ denial: null, isError: false })).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("drops a redirect that is not a same-origin path", () => {
|
|
92
|
+
// safeRedirectPath's job, asserted here because this is the call site that
|
|
93
|
+
// feeds it a value straight off window.location.
|
|
94
|
+
const gate = buildCourseAccessGate({
|
|
95
|
+
denial: courseAccessDenial(err(401)),
|
|
96
|
+
isError: true,
|
|
97
|
+
loginPath: "/i/login",
|
|
98
|
+
currentPath: "//evil.example/steal",
|
|
99
|
+
});
|
|
100
|
+
expect(gate?.actionHref).toBe("/i/login");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("buildCourseAccessSecurePrompt", () => {
|
|
105
|
+
it("offers to secure a legacy grant that opened fine, for a signed-out reader", () => {
|
|
106
|
+
const prompt = buildCourseAccessSecurePrompt({
|
|
107
|
+
data: accessData(null),
|
|
108
|
+
isAuthenticated: false,
|
|
109
|
+
signupPath: "/i/signup",
|
|
110
|
+
currentPath: "/i/course-access/abc",
|
|
111
|
+
});
|
|
112
|
+
expect(prompt).toEqual({
|
|
113
|
+
email: "buyer@example.com",
|
|
114
|
+
href: `/i/signup?redirect=${encodeURIComponent("/i/course-access/abc")}`,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("says nothing on a grant that is already bound", () => {
|
|
119
|
+
expect(buildCourseAccessSecurePrompt({ data: accessData("acc_1"), isAuthenticated: false })).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("says nothing to a signed-in reader — there is no binding we can perform for them", () => {
|
|
123
|
+
expect(buildCourseAccessSecurePrompt({ data: accessData(null), isAuthenticated: true })).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("says nothing before the course loads", () => {
|
|
127
|
+
expect(buildCourseAccessSecurePrompt({ data: null, isAuthenticated: false })).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("courseSignInHref", () => {
|
|
132
|
+
it("returns the bare path when there is nowhere safe to come back to (SSR, empty)", () => {
|
|
133
|
+
expect(courseSignInHref("/i/login", "")).toBe("/i/login");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -5,6 +5,7 @@ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
|
5
5
|
import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
|
|
6
6
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
7
7
|
import { readLanding } from "../../../utils/landing";
|
|
8
|
+
import { rememberBookingSecret } from "../../../utils/bookingSecret";
|
|
8
9
|
|
|
9
10
|
export type CourseCheckoutStep = "details" | "payment";
|
|
10
11
|
|
|
@@ -50,6 +51,13 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
50
51
|
const [error, setError] = useState<string | null>(null);
|
|
51
52
|
/** The booking created for quoting, reused so a code never mints a second one. */
|
|
52
53
|
const bookingIdRef = useRef<string | null>(null);
|
|
54
|
+
/**
|
|
55
|
+
* The per-booking credential returned by `createBooking`. Held in a ref (not
|
|
56
|
+
* state) so it never triggers a render and never lands in a dependency array,
|
|
57
|
+
* and mirrored into sessionStorage for the leg after the payment redirect.
|
|
58
|
+
* The backend returns it once and cannot reissue it.
|
|
59
|
+
*/
|
|
60
|
+
const bookingSecretRef = useRef<string | undefined>(undefined);
|
|
53
61
|
|
|
54
62
|
const finalisePath =
|
|
55
63
|
opts.finalisePath ?? ((s, bookingId) => `/i/courses/${s}/finalise?bookingId=${bookingId}`);
|
|
@@ -72,6 +80,11 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
72
80
|
...(readLanding() ?? {}),
|
|
73
81
|
});
|
|
74
82
|
bookingIdRef.current = created.bookingId;
|
|
83
|
+
bookingSecretRef.current = created.bookingSecret;
|
|
84
|
+
// Written the instant it arrives: this is the only time the server will
|
|
85
|
+
// ever hand it over, and the finalise page (post-redirect) has no other
|
|
86
|
+
// way to get it.
|
|
87
|
+
rememberBookingSecret(created.bookingId, created.bookingSecret);
|
|
75
88
|
return created.bookingId;
|
|
76
89
|
};
|
|
77
90
|
|
|
@@ -86,6 +99,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
86
99
|
const bookingId = await ensureBooking();
|
|
87
100
|
return updateBooking.mutateAsync({
|
|
88
101
|
bookingId,
|
|
102
|
+
bookingSecret: bookingSecretRef.current,
|
|
89
103
|
email,
|
|
90
104
|
firstName,
|
|
91
105
|
lastName,
|
|
@@ -119,6 +133,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
119
133
|
bookingId = await ensureBooking();
|
|
120
134
|
const updated = await updateBooking.mutateAsync({
|
|
121
135
|
bookingId,
|
|
136
|
+
bookingSecret: bookingSecretRef.current,
|
|
122
137
|
email,
|
|
123
138
|
firstName,
|
|
124
139
|
lastName,
|
|
@@ -138,6 +153,8 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
138
153
|
...(readLanding() ?? {}),
|
|
139
154
|
});
|
|
140
155
|
bookingIdRef.current = data.bookingId;
|
|
156
|
+
bookingSecretRef.current = data.bookingSecret;
|
|
157
|
+
rememberBookingSecret(data.bookingId, data.bookingSecret);
|
|
141
158
|
bookingId = data.bookingId;
|
|
142
159
|
isFree = !!data.isFree;
|
|
143
160
|
}
|
|
@@ -150,7 +167,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
150
167
|
else if (typeof window !== "undefined") window.location.href = ru;
|
|
151
168
|
return;
|
|
152
169
|
}
|
|
153
|
-
const result = await flow.start({ bookingId, returnUrl: ru });
|
|
170
|
+
const result = await flow.start({ bookingId, bookingSecret: bookingSecretRef.current, returnUrl: ru });
|
|
154
171
|
if (result.provider && result.provider !== "stripe") return;
|
|
155
172
|
setStep("payment");
|
|
156
173
|
} catch (e) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { CourseAccessData, CourseLesson } from "../../../types/models";
|
|
3
|
+
import {
|
|
4
|
+
courseAccessDenial,
|
|
5
|
+
isLegacyCourseAccess,
|
|
6
|
+
useCourseAccess,
|
|
7
|
+
useUpdateCourseProgress,
|
|
8
|
+
type CourseAccessDenial,
|
|
9
|
+
} from "../../../data/queries/useCourseAccess";
|
|
10
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
11
|
+
import { safeRedirectPath } from "../../../utils/safeRedirect";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the classroom should draw INSTEAD of the player.
|
|
15
|
+
*
|
|
16
|
+
* `sign_in_required` and `wrong_account` are the two halves of account-bound
|
|
17
|
+
* course access, and they must never be merged: one caller needs a way IN, the
|
|
18
|
+
* other needs a way OUT of the session they are already in. `not_found` keeps
|
|
19
|
+
* the pre-existing behaviour for a revoked or unknown grant.
|
|
20
|
+
*/
|
|
21
|
+
export type CourseAccessGateKind = "sign_in_required" | "wrong_account" | "not_found";
|
|
22
|
+
|
|
23
|
+
export interface CourseAccessGate {
|
|
24
|
+
kind: CourseAccessGateKind;
|
|
25
|
+
title: string;
|
|
26
|
+
/** The API's sentence when it sent one, else a local fallback. */
|
|
27
|
+
body: string;
|
|
28
|
+
/** Primary CTA label. */
|
|
29
|
+
actionLabel: string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Where the primary CTA goes. `null` on `wrong_account`, whose action is
|
|
32
|
+
* signing OUT — a state change, not a navigation, so the caller runs
|
|
33
|
+
* `signOutAndRetry` instead.
|
|
34
|
+
*/
|
|
35
|
+
actionHref: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Not re-exported from the headless barrel — `COURSE_SIGNUP_PATH` is already
|
|
39
|
+
// taken there by the posts upsell, and these are only defaults for the options.
|
|
40
|
+
export const COURSE_LOGIN_PATH = "/login";
|
|
41
|
+
export const COURSE_SIGNUP_PATH = "/signup";
|
|
42
|
+
|
|
43
|
+
/** `loginPath?redirect=<here>` — a sign-in that lands back on the course, never on a generic home. */
|
|
44
|
+
export const courseSignInHref = (loginPath: string, currentPath: string): string => {
|
|
45
|
+
const target = safeRedirectPath(currentPath, "");
|
|
46
|
+
return target ? `${loginPath}?redirect=${encodeURIComponent(target)}` : loginPath;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The gate for a failed access read, or `null` when there is nothing to gate.
|
|
51
|
+
*
|
|
52
|
+
* Pure and total so the two 4xx branches can be exercised without a DOM, a
|
|
53
|
+
* client or a session — this mapping is the whole feature, and getting the
|
|
54
|
+
* 401/403 arms backwards produces a page that is technically correct and
|
|
55
|
+
* practically a dead end.
|
|
56
|
+
*/
|
|
57
|
+
export function buildCourseAccessGate(input: {
|
|
58
|
+
denial: CourseAccessDenial | null;
|
|
59
|
+
/** The query failed for some OTHER reason (404, 500, offline). */
|
|
60
|
+
isError: boolean;
|
|
61
|
+
loginPath?: string;
|
|
62
|
+
/** Path (+ search) to return to after signing in. */
|
|
63
|
+
currentPath?: string;
|
|
64
|
+
}): CourseAccessGate | null {
|
|
65
|
+
const { denial, isError } = input;
|
|
66
|
+
const loginPath = input.loginPath ?? COURSE_LOGIN_PATH;
|
|
67
|
+
|
|
68
|
+
if (denial?.reason === "account_required") {
|
|
69
|
+
return {
|
|
70
|
+
kind: "sign_in_required",
|
|
71
|
+
title: "Sign in to open this course",
|
|
72
|
+
// Said plainly because the person reading it is almost always the buyer,
|
|
73
|
+
// arriving from an email they were sent months ago.
|
|
74
|
+
body:
|
|
75
|
+
denial.message ??
|
|
76
|
+
"This enrolment is tied to an account. Sign in and you'll come straight back to your course.",
|
|
77
|
+
actionLabel: "Sign in",
|
|
78
|
+
actionHref: courseSignInHref(loginPath, input.currentPath ?? ""),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (denial?.reason === "wrong_account") {
|
|
83
|
+
return {
|
|
84
|
+
kind: "wrong_account",
|
|
85
|
+
title: "This enrolment belongs to a different account",
|
|
86
|
+
// NOT "sign in" — they already are. The only useful move is leaving the
|
|
87
|
+
// session they are in.
|
|
88
|
+
body:
|
|
89
|
+
denial.message ??
|
|
90
|
+
"You're signed in with a different account. Sign out and sign back in with the account that bought this course.",
|
|
91
|
+
actionLabel: "Sign out and switch account",
|
|
92
|
+
actionHref: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isError) {
|
|
97
|
+
return {
|
|
98
|
+
kind: "not_found",
|
|
99
|
+
title: "Course access not found",
|
|
100
|
+
body: "This link may have expired or the enrolment may have been revoked.",
|
|
101
|
+
actionLabel: null,
|
|
102
|
+
actionHref: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The nudge shown ON TOP of a course that opened fine, when the grant is still
|
|
111
|
+
* legacy (`account_id` NULL) — i.e. the link alone is the credential.
|
|
112
|
+
*
|
|
113
|
+
* Only offered to a signed-OUT reader. There is no bind-my-grant endpoint, so
|
|
114
|
+
* the honest thing to offer is an account on the grant's own email; telling a
|
|
115
|
+
* reader who is already signed in to "secure" something we cannot bind for them
|
|
116
|
+
* would be noise on every page view.
|
|
117
|
+
*/
|
|
118
|
+
export interface CourseAccessSecurePrompt {
|
|
119
|
+
/** The email the grant was issued to — shown so the reader knows which one to use. */
|
|
120
|
+
email: string;
|
|
121
|
+
href: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildCourseAccessSecurePrompt(input: {
|
|
125
|
+
data?: CourseAccessData | null;
|
|
126
|
+
isAuthenticated: boolean;
|
|
127
|
+
signupPath?: string;
|
|
128
|
+
currentPath?: string;
|
|
129
|
+
}): CourseAccessSecurePrompt | null {
|
|
130
|
+
if (!input.data || input.isAuthenticated) return null;
|
|
131
|
+
if (!isLegacyCourseAccess(input.data)) return null;
|
|
132
|
+
const email = input.data.access.email;
|
|
133
|
+
if (!email) return null;
|
|
134
|
+
return {
|
|
135
|
+
email,
|
|
136
|
+
href: courseSignInHref(input.signupPath ?? COURSE_SIGNUP_PATH, input.currentPath ?? ""),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const currentPath = (): string =>
|
|
141
|
+
typeof window === "undefined" ? "" : `${window.location.pathname}${window.location.search}`;
|
|
142
|
+
|
|
143
|
+
export interface UseCourseClassroomOptions {
|
|
144
|
+
/** Sign-in path (default `/login`; site-starter uses `/i/login`). */
|
|
145
|
+
loginPath?: string;
|
|
146
|
+
/** Signup path for the secure-your-access nudge (default `/signup`). */
|
|
147
|
+
signupPath?: string;
|
|
148
|
+
/** Suppress the legacy-grant nudge entirely. */
|
|
149
|
+
showSecurePrompt?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Headless enrolled-course player: the account-binding gate, lesson selection
|
|
154
|
+
* and throttled video-progress reporting over `useCourseAccess` +
|
|
155
|
+
* `useUpdateCourseProgress`. Bring your own UI.
|
|
156
|
+
*/
|
|
157
|
+
export function useCourseClassroom(accessId?: string, opts: UseCourseClassroomOptions = {}) {
|
|
158
|
+
const { isAuthenticated, isInitialized, user, logout } = usePublicAuth();
|
|
159
|
+
/**
|
|
160
|
+
* Held until the session is restored. `PublicAuthProvider` reads the stored
|
|
161
|
+
* token in an effect, so a query fired on the first render goes out with no
|
|
162
|
+
* Authorization header — and a BOUND grant would answer 401 to the very person
|
|
163
|
+
* it belongs to, showing "sign in" to someone already signed in. Costs one
|
|
164
|
+
* `/public/accounts/me` round trip that the page makes anyway.
|
|
165
|
+
*/
|
|
166
|
+
const { data, isLoading, isError, error, refetch } = useCourseAccess(isInitialized ? accessId : undefined);
|
|
167
|
+
const updateProgress = useUpdateCourseProgress(accessId);
|
|
168
|
+
const lastSent = useRef(0);
|
|
169
|
+
|
|
170
|
+
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
|
171
|
+
const [securePromptDismissed, setSecurePromptDismissed] = useState(false);
|
|
172
|
+
|
|
173
|
+
const denial = useMemo(() => (isError ? courseAccessDenial(error) : null), [isError, error]);
|
|
174
|
+
|
|
175
|
+
const gate = useMemo(
|
|
176
|
+
() => buildCourseAccessGate({ denial, isError, loginPath: opts.loginPath, currentPath: currentPath() }),
|
|
177
|
+
[denial, isError, opts.loginPath],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const modules = data?.course.modules ?? [];
|
|
181
|
+
const lessons: CourseLesson[] = modules.flatMap((m) => m.lessons ?? []);
|
|
182
|
+
|
|
183
|
+
// The server's resume point wins until the reader picks a lesson; deriving it
|
|
184
|
+
// rather than seeding state means it is not frozen at whatever the very first
|
|
185
|
+
// (undefined) render saw.
|
|
186
|
+
const selected =
|
|
187
|
+
lessons.find((l) => l.id === selectedLessonId) ??
|
|
188
|
+
lessons.find((l) => l.id === data?.access.currentLessonId) ??
|
|
189
|
+
lessons[0];
|
|
190
|
+
|
|
191
|
+
const securePrompt = useMemo(() => {
|
|
192
|
+
if (opts.showSecurePrompt === false || securePromptDismissed) return null;
|
|
193
|
+
return buildCourseAccessSecurePrompt({
|
|
194
|
+
data,
|
|
195
|
+
isAuthenticated,
|
|
196
|
+
signupPath: opts.signupPath,
|
|
197
|
+
currentPath: currentPath(),
|
|
198
|
+
});
|
|
199
|
+
}, [data, isAuthenticated, opts.showSecurePrompt, opts.signupPath, securePromptDismissed]);
|
|
200
|
+
|
|
201
|
+
/** Throttled to ~1/s — `timeupdate` fires several times a second. */
|
|
202
|
+
const reportProgress = (videoProgress: number, duration?: number) => {
|
|
203
|
+
if (!selected || !duration) return;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
if (now - lastSent.current < 1000) return;
|
|
206
|
+
lastSent.current = now;
|
|
207
|
+
const percent = (videoProgress / duration) * 100;
|
|
208
|
+
updateProgress.mutate({ lessonId: selected.id, videoProgress: percent, isCompleted: percent >= 95 });
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The `wrong_account` exit. Clears the session and re-asks: a LEGACY grant
|
|
213
|
+
* opens immediately once anonymous, and a bound one falls through to the
|
|
214
|
+
* sign-in gate — either way the reader moves forward instead of in a circle.
|
|
215
|
+
*/
|
|
216
|
+
const signOutAndRetry = async () => {
|
|
217
|
+
await logout();
|
|
218
|
+
await refetch();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
data,
|
|
223
|
+
// A disabled query is not "loading" to react-query, so the pre-session
|
|
224
|
+
// window has to be folded in by hand or the page flashes its error state.
|
|
225
|
+
isLoading: isLoading || !isInitialized,
|
|
226
|
+
/** Non-null when the player must not be drawn. */
|
|
227
|
+
gate,
|
|
228
|
+
denial,
|
|
229
|
+
securePrompt,
|
|
230
|
+
dismissSecurePrompt: () => setSecurePromptDismissed(true),
|
|
231
|
+
signOutAndRetry,
|
|
232
|
+
/** The email of the account currently signed in — names it in the wrong-account copy. */
|
|
233
|
+
signedInEmail: user?.email ?? null,
|
|
234
|
+
isAuthenticated,
|
|
235
|
+
modules,
|
|
236
|
+
lessons,
|
|
237
|
+
selected,
|
|
238
|
+
selectLesson: setSelectedLessonId,
|
|
239
|
+
completedCount: lessons.filter((l) => l.progress?.isCompleted).length,
|
|
240
|
+
reportProgress,
|
|
241
|
+
};
|
|
242
|
+
}
|