@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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/CartContext.tsx +25 -1
  3. package/src/data/queries/useCheckouts.ts +14 -0
  4. package/src/data/queries/useCoachingAvailability.ts +13 -0
  5. package/src/data/queries/useCourseAccess.ts +81 -0
  6. package/src/data/queries/useCourses.ts +19 -1
  7. package/src/data/queries/useEvents.ts +6 -0
  8. package/src/data/queries/useFinalize.ts +24 -4
  9. package/src/index.ts +7 -0
  10. package/src/types/models.ts +41 -1
  11. package/src/ui/format/_tests/pwyw.spec.ts +157 -0
  12. package/src/ui/format/pwyw.ts +95 -0
  13. package/src/ui/headless/booking/useBookingSecret.ts +41 -0
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
  15. package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
  16. package/src/ui/headless/course/useCourseCheckout.ts +18 -1
  17. package/src/ui/headless/course/useCourseClassroom.ts +242 -0
  18. package/src/ui/headless/event/useEventCheckout.ts +96 -6
  19. package/src/ui/headless/index.ts +11 -0
  20. package/src/ui/index.ts +17 -0
  21. package/src/ui/styled/AccountDashboard.tsx +7 -0
  22. package/src/ui/styled/Cart.tsx +11 -8
  23. package/src/ui/styled/CartLineOptions.tsx +107 -0
  24. package/src/ui/styled/Checkout.tsx +5 -0
  25. package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
  26. package/src/ui/styled/CoachingBooking.tsx +9 -1
  27. package/src/ui/styled/CoachingConfirmation.tsx +44 -3
  28. package/src/ui/styled/Confirmation.tsx +94 -0
  29. package/src/ui/styled/CourseAccess.tsx +191 -51
  30. package/src/ui/styled/CourseCheckout.tsx +9 -1
  31. package/src/ui/styled/CourseConfirmation.tsx +42 -3
  32. package/src/ui/styled/EventTickets.tsx +114 -0
  33. package/src/ui/styled/InvoicePayment.tsx +65 -11
  34. package/src/ui/styled/ProductDetail.tsx +8 -0
  35. package/src/utils/_tests/bookingSecret.spec.ts +115 -0
  36. 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) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
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
+ }