@tribe-nest/forge 3.48.0 → 3.52.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 (38) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCohortEnrolment.ts +87 -0
  3. package/src/data/queries/useMembershipCalls.ts +58 -8
  4. package/src/i18n/de.json +46 -0
  5. package/src/i18n/en.json +46 -0
  6. package/src/index.ts +1 -0
  7. package/src/types/models.ts +78 -5
  8. package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
  9. package/src/ui/headless/memberHome/useMemberCalls.ts +13 -0
  10. package/src/ui/index.ts +10 -0
  11. package/src/ui/styled/BlogPost.tsx +1 -1
  12. package/src/ui/styled/ChatRoom.tsx +2 -2
  13. package/src/ui/styled/Checkout.tsx +125 -125
  14. package/src/ui/styled/CoachingBooking.tsx +1 -1
  15. package/src/ui/styled/CoachingDetail.tsx +4 -4
  16. package/src/ui/styled/CohortCheckout.tsx +450 -0
  17. package/src/ui/styled/CohortConfirmation.tsx +259 -0
  18. package/src/ui/styled/CohortPage.tsx +31 -1
  19. package/src/ui/styled/CourseAccess.tsx +19 -6
  20. package/src/ui/styled/CourseCallsSection.tsx +99 -0
  21. package/src/ui/styled/CourseDetail.tsx +4 -4
  22. package/src/ui/styled/EventDetail.tsx +4 -4
  23. package/src/ui/styled/FormRenderer.tsx +14 -4
  24. package/src/ui/styled/PaystackPayButton.tsx +1 -1
  25. package/src/ui/styled/PodcastEpisode.tsx +1 -1
  26. package/src/ui/styled/ProductDetail.tsx +4 -4
  27. package/src/ui/styled/_tests/CohortCheckout.spec.tsx +232 -0
  28. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
  29. package/src/ui/styled/_tests/packagedCssIsCurrent.spec.ts +43 -0
  30. package/src/ui/styled/_tests/utilityPrefix.spec.ts +156 -0
  31. package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +1 -1
  32. package/src/ui/styled/chat/ChatThreadPanel.tsx +1 -1
  33. package/src/ui/styled/forge-utilities.css +463 -731
  34. package/src/ui/styled/members/MemberCallsCalendar.tsx +21 -3
  35. package/src/ui/styled/members/MemberHomeLayout.tsx +4 -4
  36. package/src/ui/styled/members/MemberPostsFeed.tsx +1 -1
  37. package/src/ui/styled/members/PostCollectionsGrid.tsx +1 -1
  38. package/src/ui/styled/musicLinkStyles.tsx +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.48.0",
3
+ "version": "3.52.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -17,7 +17,7 @@
17
17
  "src"
18
18
  ],
19
19
  "scripts": {
20
- "build:css": "tailwindcss -i tailwind.input.css -o src/ui/styled/forge-utilities.css && node scripts/wrapForgeCss.mjs",
20
+ "build:css": "tailwindcss -i tailwind.input.css -o src/ui/styled/forge-utilities.css",
21
21
  "prepack": "npm run build:css",
22
22
  "check-types": "tsc --noEmit",
23
23
  "test": "vitest run",
@@ -0,0 +1,87 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import type { CourseBooking } from "../../types/models";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+
5
+ /**
6
+ * Buying a seat on a cohort.
7
+ *
8
+ * A DIFFERENT CHECKOUT FROM THE SELF-PACED ONE, and not by preference. A cohort
9
+ * is priced from its `course_pricing_plans` rows, so the buyer picks a PLAN and
10
+ * the plan is the price: there is no amount, no currency and no coupon field on
11
+ * this body. `courses.price` is 0 on every cohort course, which is exactly why
12
+ * the self-paced endpoint refuses one outright rather than selling it for
13
+ * nothing.
14
+ *
15
+ * The plan also decides whether this is one payment or several. An instalment
16
+ * plan takes a second consent at `start-payment` ("charge me again when nobody
17
+ * is here"), because instalments 2..n are taken months later by a cron.
18
+ */
19
+
20
+ export type CreateCohortEnrolmentInput = {
21
+ planId: string;
22
+ email: string;
23
+ firstName: string;
24
+ lastName: string;
25
+ questionnaire?: unknown;
26
+ attributionRefId?: string;
27
+ browserSessionId?: string;
28
+ };
29
+
30
+ export type CreateCohortEnrolmentResult = {
31
+ bookingId: string;
32
+ /**
33
+ * The per-booking credential, returned EXACTLY ONCE and never re-readable.
34
+ * Every later call on this booking must present it. Persist it the instant it
35
+ * arrives - see `utils/bookingSecret`.
36
+ */
37
+ bookingSecret?: string;
38
+ cohortId: string;
39
+ planId: string;
40
+ courseId: string;
41
+ totalAmount: number;
42
+ currency: string;
43
+ installmentCount: number;
44
+ /** What is charged TODAY. Equals `totalAmount` on a single-payment plan. */
45
+ firstPaymentAmount: number;
46
+ isFree: boolean;
47
+ };
48
+
49
+ /** Reserve a seat on a cohort against one pricing plan. */
50
+ export function useCreateCohortEnrolment(cohortId?: string) {
51
+ const { client, profileId } = useForge();
52
+
53
+ return useMutation<CreateCohortEnrolmentResult, unknown, CreateCohortEnrolmentInput>({
54
+ mutationFn: async (body) => {
55
+ const res = await client.post(`/public/cohorts/${cohortId}/enrollments`, {
56
+ profileId,
57
+ cohortId,
58
+ ...body,
59
+ });
60
+ return res.data;
61
+ },
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Finalize a cohort enrolment (`/public/cohorts/:cohortId/finalize`).
67
+ *
68
+ * Same shape and same reasoning as the self-paced finalize: the confirmation
69
+ * page is reached through a provider redirect, so the caller reads the booking
70
+ * secret back out of `utils/bookingSecret` rather than off in-memory state.
71
+ */
72
+ export function useCohortEnrolmentFinalize(cohortId?: string, bookingId?: string, bookingSecret?: string) {
73
+ const { client, profileId } = useForge();
74
+
75
+ return useQuery<CourseBooking>({
76
+ queryKey: ["cohortEnrolmentStatus", profileId, bookingId],
77
+ queryFn: async () => {
78
+ const res = await client.post(`/public/cohorts/${cohortId}/finalize`, {
79
+ profileId,
80
+ bookingId,
81
+ bookingSecret,
82
+ });
83
+ return res.data;
84
+ },
85
+ enabled: !!profileId && !!client && !!cohortId && !!bookingId,
86
+ });
87
+ }
@@ -2,12 +2,23 @@ import { useQuery } from "@tanstack/react-query";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
 
4
4
  /**
5
- * The member's own call calendar (`GET /public/membership-calls/mine`).
5
+ * The viewer's own call calendar (`GET /public/scheduled-calls/mine`).
6
6
  *
7
7
  * Lives in Forge rather than in either app because BOTH rendering surfaces need
8
8
  * exactly this - the site-starter's `/i/members/calendar` and the client app's
9
- * `/i/members/calendar` - and a second copy is how the two drift, as
10
- * `useMyTickets` and `useMyBookings` already document.
9
+ * `/i/members/calendar`, and now the classroom and cohort pages on both - and a
10
+ * second copy is how the two drift, as `useMyTickets` and `useMyBookings`
11
+ * already document.
12
+ *
13
+ * ## It was never only memberships
14
+ *
15
+ * The server resolves the caller through every registered audience kind, so a
16
+ * course enrolment and a cohort seat come back alongside membership tiers. The
17
+ * endpoint answered on `/public/membership-calls/mine` for a year while doing
18
+ * exactly that; that path is kept as an alias for sites published against older
19
+ * Forge builds, and this file now calls the honest one. The names here keep the
20
+ * `MembershipCall` spelling because they are PUBLISHED exports that a code site
21
+ * may import; `useMyScheduledCalls` is the alias to write new code against.
11
22
  *
12
23
  * ## What the server owns and this file does not mirror
13
24
  *
@@ -84,24 +95,49 @@ export const membershipCallKeys = {
84
95
  * @param options.from Inclusive lower bound. Absent means "from now on", which is
85
96
  * what a caller wanting only the next few calls sends.
86
97
  * @param options.to Inclusive upper bound. Absent means "however far ahead".
98
+ * @param options.courseId Show only calls addressed to this course. See below.
99
+ * @param options.cohortId Show only calls addressed to this cohort. See below.
100
+ *
101
+ * ## `courseId` / `cohortId` narrow, they do not unlock
102
+ *
103
+ * Absent, this is the viewer's whole calendar and behaves as it always has.
104
+ * Present, the server INTERSECTS them with the audience keys it resolved for
105
+ * this caller, so they can only remove rows. Passing a course the viewer has no
106
+ * access to returns nothing, which is the same answer a stranger gets.
107
+ *
108
+ * The classroom and the cohort page pass them because a student enrolled in two
109
+ * cohorts opening one of them must not be shown the other's sessions.
87
110
  */
88
111
  export function useMyMembershipCalls(options?: {
89
112
  from?: Date;
90
113
  to?: Date;
91
114
  limit?: number;
92
115
  enabled?: boolean;
116
+ courseId?: string | null;
117
+ cohortId?: string | null;
93
118
  }) {
94
119
  const { client, profileId } = useForge();
95
120
  const from = options?.from?.toISOString();
96
121
  const to = options?.to?.toISOString();
122
+ const courseId = options?.courseId || undefined;
123
+ const cohortId = options?.cohortId || undefined;
97
124
  return useQuery<MyMembershipCall[]>({
98
125
  // The WINDOW is part of the key: paging back a month is a different question
99
126
  // with a different answer, and sharing one cache entry would show February's
100
- // calls in March until a refetch landed.
101
- queryKey: [...membershipCallKeys.mine(profileId), from ?? "now", to ?? "open"],
127
+ // calls in March until a refetch landed. The SCOPE is in it for the same
128
+ // reason - the classroom's answer and the members calendar's answer are two
129
+ // different lists, and sharing one entry would put one course's sessions on
130
+ // another course's page for as long as the cache held.
131
+ queryKey: [
132
+ ...membershipCallKeys.mine(profileId),
133
+ from ?? "now",
134
+ to ?? "open",
135
+ courseId ?? "any-course",
136
+ cohortId ?? "any-cohort",
137
+ ],
102
138
  queryFn: async () => {
103
- const res = await client.get("/public/membership-calls/mine", {
104
- params: { profileId, from, to, limit: options?.limit },
139
+ const res = await client.get("/public/scheduled-calls/mine", {
140
+ params: { profileId, from, to, limit: options?.limit, courseId, cohortId },
105
141
  });
106
142
  return res.data;
107
143
  },
@@ -146,7 +182,7 @@ export function useMyMembershipCallRecordings(options?: { limit?: number; enable
146
182
  return useQuery<MyMembershipCallRecording[]>({
147
183
  queryKey: ["membership-calls", "recordings", profileId],
148
184
  queryFn: async () => {
149
- const res = await client.get("/public/membership-calls/mine/recordings", {
185
+ const res = await client.get("/public/scheduled-calls/mine/recordings", {
150
186
  params: { profileId, limit: options?.limit },
151
187
  });
152
188
  return res.data;
@@ -160,3 +196,17 @@ export function useMyMembershipCallRecordings(options?: { limit?: number; enable
160
196
  refetchInterval: 120_000,
161
197
  });
162
198
  }
199
+
200
+ /**
201
+ * The honest names for the two hooks above.
202
+ *
203
+ * Aliases rather than renames: `useMyMembershipCalls` and its types are
204
+ * PUBLISHED exports that a code-built site may already import, and breaking
205
+ * those to fix a word would cost a site owner a build for nothing. New code
206
+ * should reach for these; the old names stay for as long as anything imports
207
+ * them.
208
+ */
209
+ export const useMyScheduledCalls = useMyMembershipCalls;
210
+ export const useMyScheduledCallRecordings = useMyMembershipCallRecordings;
211
+ export type MyScheduledCall = MyMembershipCall;
212
+ export type MyScheduledCallRecording = MyMembershipCallRecording;
package/src/i18n/de.json CHANGED
@@ -529,6 +529,51 @@
529
529
  "forge.coaching_detail.about_title": "Über diese Session",
530
530
  "forge.coaching_detail.duration_minutes": "{minutes} Minuten",
531
531
  "forge.coaching_detail.not_found": "Coaching-Session nicht gefunden.",
532
+ "forge.cohort_checkout.aria_close": "Schließen",
533
+ "forge.cohort_checkout.authorise_future_payments": "Autorisiere die späteren Zahlungen dieses Plans.",
534
+ "forge.cohort_checkout.back": "Zurück",
535
+ "forge.cohort_checkout.confirm_leave": "Ohne Abschluss verlassen? Dein Platz ist noch nicht reserviert.",
536
+ "forge.cohort_checkout.continue_to_payment": "Weiter zur Zahlung",
537
+ "forge.cohort_checkout.details_subtitle": "Wir senden deinen Zugang an diese Adresse.",
538
+ "forge.cohort_checkout.details_title": "Deine Daten",
539
+ "forge.cohort_checkout.due_now": "Jetzt fällig",
540
+ "forge.cohort_checkout.email_placeholder": "E-Mail",
541
+ "forge.cohort_checkout.first_name_placeholder": "Vorname",
542
+ "forge.cohort_checkout.installment_note": "{count} Zahlungen insgesamt, zusammen {total}.",
543
+ "forge.cohort_checkout.last_name_placeholder": "Nachname",
544
+ "forge.cohort_checkout.no_plans": "Diese Kohorte ist noch nicht zur Anmeldung geöffnet.",
545
+ "forge.cohort_checkout.pay": "{amount} zahlen",
546
+ "forge.cohort_checkout.payment_not_configured": "Zahlung ist nicht konfiguriert.",
547
+ "forge.cohort_checkout.payment_subtitle": "Schließe deine Anmeldung ab, um der Kohorte beizutreten.",
548
+ "forge.cohort_checkout.payment_title": "Zahlung",
549
+ "forge.cohort_checkout.plan_in_full": "Auf einmal zahlen",
550
+ "forge.cohort_checkout.plan_installments": "{count} Zahlungen à {amount}, alle {days} Tage",
551
+ "forge.cohort_checkout.plan_subtitle": "Wähle, wie du zahlen möchtest.",
552
+ "forge.cohort_checkout.plan_title": "Plan wählen",
553
+ "forge.cohort_checkout.preparing_payment": "Zahlung wird vorbereitet...",
554
+ "forge.cohort_checkout.tax_added": "+ {amount} Steuer",
555
+ "forge.cohort_checkout.title": "Anmelden",
556
+ "forge.cohort_checkout.trigger": "Anmelden",
557
+ "forge.cohort_checkout.working": "Einen Moment...",
558
+ "forge.cohort_confirmation.body_confirmed": "Du bist dabei, {first_name}. Bis zum Start.",
559
+ "forge.cohort_confirmation.body_confirmed_no_name": "Du bist dabei. Bis zum Start.",
560
+ "forge.cohort_confirmation.body_incomplete": "Deine Anmeldung ist {status}. Von dir wird gerade nichts weiter gebraucht.",
561
+ "forge.cohort_confirmation.emailed": "Wir haben die Details an {email} geschickt.",
562
+ "forge.cohort_confirmation.explore": "Mehr entdecken",
563
+ "forge.cohort_confirmation.eyebrow_action_needed": "Aktion nötig",
564
+ "forge.cohort_confirmation.eyebrow_confirmed": "Angemeldet",
565
+ "forge.cohort_confirmation.free": "Kostenlos",
566
+ "forge.cohort_confirmation.loading": "Anmeldung wird bestätigt...",
567
+ "forge.cohort_confirmation.lost_body": "Dieser Browser kennt diese Anmeldung nicht, wir können sie hier nicht anzeigen. Falls du bezahlt hast, sieh in deinen E-Mails nach der Bestätigung.",
568
+ "forge.cohort_confirmation.lost_restart": "Zurück zur Kohorte",
569
+ "forge.cohort_confirmation.lost_title": "Wir können diese Anmeldung hier nicht öffnen",
570
+ "forge.cohort_confirmation.not_found": "Anmeldung nicht gefunden.",
571
+ "forge.cohort_confirmation.not_given": "Nicht angegeben",
572
+ "forge.cohort_confirmation.row_paid": "Heute gezahlt",
573
+ "forge.cohort_confirmation.row_reference": "Referenz",
574
+ "forge.cohort_confirmation.row_student": "Teilnehmer",
575
+ "forge.cohort_confirmation.title_confirmed": "Dein Platz ist sicher",
576
+ "forge.cohort_confirmation.title_incomplete": "Fast geschafft",
532
577
  "forge.cohort_page.curriculum_title": "Lehrplan",
533
578
  "forge.cohort_page.date_range": "{start} bis {end}",
534
579
  "forge.cohort_page.date_tbd": "Noch offen",
@@ -572,6 +617,7 @@
572
617
  "forge.course_access.secure_prompt_lead": "Jeder mit diesem Link kann deinen Kurs öffnen. Erstelle ein Konto mit",
573
618
  "forge.course_access.secure_prompt_tail": "damit er nur dir gehört.",
574
619
  "forge.course_access.signed_in_as": "Angemeldet als",
620
+ "forge.course_calls.title": "Live-Calls",
575
621
  "forge.course_checkout.aria_close": "Schließen",
576
622
  "forge.course_checkout.back": "Zurück",
577
623
  "forge.course_checkout.confirm_email_placeholder": "E-Mail bestätigen",
package/src/i18n/en.json CHANGED
@@ -529,6 +529,51 @@
529
529
  "forge.coaching_detail.about_title": "About this session",
530
530
  "forge.coaching_detail.duration_minutes": "{minutes} minutes",
531
531
  "forge.coaching_detail.not_found": "Coaching session not found.",
532
+ "forge.cohort_checkout.aria_close": "Close",
533
+ "forge.cohort_checkout.authorise_future_payments": "Authorise the later payments on this plan.",
534
+ "forge.cohort_checkout.back": "Back",
535
+ "forge.cohort_checkout.confirm_leave": "Leave without finishing? Your seat is not held yet.",
536
+ "forge.cohort_checkout.continue_to_payment": "Continue to payment",
537
+ "forge.cohort_checkout.details_subtitle": "We will send your access to this address.",
538
+ "forge.cohort_checkout.details_title": "Your details",
539
+ "forge.cohort_checkout.due_now": "Due now",
540
+ "forge.cohort_checkout.email_placeholder": "Email",
541
+ "forge.cohort_checkout.first_name_placeholder": "First name",
542
+ "forge.cohort_checkout.installment_note": "{count} payments in total, {total} altogether.",
543
+ "forge.cohort_checkout.last_name_placeholder": "Last name",
544
+ "forge.cohort_checkout.no_plans": "This cohort is not open for enrolment yet.",
545
+ "forge.cohort_checkout.pay": "Pay {amount}",
546
+ "forge.cohort_checkout.payment_not_configured": "Payment is not configured.",
547
+ "forge.cohort_checkout.payment_subtitle": "Complete your enrolment to join the cohort.",
548
+ "forge.cohort_checkout.payment_title": "Payment",
549
+ "forge.cohort_checkout.plan_in_full": "Pay in full",
550
+ "forge.cohort_checkout.plan_installments": "{count} payments of {amount}, every {days} days",
551
+ "forge.cohort_checkout.plan_subtitle": "Choose how you would like to pay.",
552
+ "forge.cohort_checkout.plan_title": "Choose a plan",
553
+ "forge.cohort_checkout.preparing_payment": "Preparing payment...",
554
+ "forge.cohort_checkout.tax_added": "+ {amount} tax",
555
+ "forge.cohort_checkout.title": "Enrol",
556
+ "forge.cohort_checkout.trigger": "Enrol",
557
+ "forge.cohort_checkout.working": "Working...",
558
+ "forge.cohort_confirmation.body_confirmed": "You're in, {first_name}. See you at the start.",
559
+ "forge.cohort_confirmation.body_confirmed_no_name": "You're in. See you at the start.",
560
+ "forge.cohort_confirmation.body_incomplete": "Your enrolment is {status}. Nothing else is needed from you right now.",
561
+ "forge.cohort_confirmation.emailed": "We've emailed the details to {email}.",
562
+ "forge.cohort_confirmation.explore": "Browse more",
563
+ "forge.cohort_confirmation.eyebrow_action_needed": "Action needed",
564
+ "forge.cohort_confirmation.eyebrow_confirmed": "Enrolled",
565
+ "forge.cohort_confirmation.free": "Free",
566
+ "forge.cohort_confirmation.loading": "Confirming your enrolment...",
567
+ "forge.cohort_confirmation.lost_body": "This browser has no record of that enrolment, so we cannot show it. If you paid, check your email for the confirmation.",
568
+ "forge.cohort_confirmation.lost_restart": "Back to the cohort",
569
+ "forge.cohort_confirmation.lost_title": "We cannot open this enrolment here",
570
+ "forge.cohort_confirmation.not_found": "Enrolment not found.",
571
+ "forge.cohort_confirmation.not_given": "Not given",
572
+ "forge.cohort_confirmation.row_paid": "Paid today",
573
+ "forge.cohort_confirmation.row_reference": "Reference",
574
+ "forge.cohort_confirmation.row_student": "Student",
575
+ "forge.cohort_confirmation.title_confirmed": "You have your seat",
576
+ "forge.cohort_confirmation.title_incomplete": "Almost there",
532
577
  "forge.cohort_page.curriculum_title": "Curriculum",
533
578
  "forge.cohort_page.date_range": "{start} to {end}",
534
579
  "forge.cohort_page.date_tbd": "TBD",
@@ -572,6 +617,7 @@
572
617
  "forge.course_access.secure_prompt_lead": "Anyone with this link can open your course. Create an account with",
573
618
  "forge.course_access.secure_prompt_tail": "to keep it to yourself.",
574
619
  "forge.course_access.signed_in_as": "Signed in as",
620
+ "forge.course_calls.title": "Live calls",
575
621
  "forge.course_checkout.aria_close": "Close",
576
622
  "forge.course_checkout.back": "Back",
577
623
  "forge.course_checkout.confirm_email_placeholder": "Confirm email",
package/src/index.ts CHANGED
@@ -109,6 +109,7 @@ export * from "./data/queries/useForms";
109
109
  export * from "./data/queries/useReplay";
110
110
  export * from "./data/queries/useCoachingAvailability";
111
111
  export * from "./data/queries/useCohortPage";
112
+ export * from "./data/queries/useCohortEnrolment";
112
113
  export * from "./data/queries/useCourseAccess";
113
114
  // Films and series: the catalogue, one title, the buyer's library, the rental
114
115
  // money legs, and the three playback-session calls. Everything a custom viewing
@@ -101,6 +101,26 @@ export type PaymentStartResponse = {
101
101
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
102
102
  /** Authoritative sales-tax quote for this charge (all wired pillars). */
103
103
  taxQuote?: PublicTaxQuote;
104
+ /**
105
+ * COHORT ENROLMENT ONLY: what THIS charge takes, when the plan is paid in
106
+ * instalments. `totalAmount` above stays the whole plan, so a checkout that
107
+ * shows only the total tells an instalment buyer they are about to be charged
108
+ * three times what they will be. Equal to `totalAmount` on a single payment.
109
+ */
110
+ installmentAmount?: number;
111
+ /** COHORT ENROLMENT ONLY: how many payments the chosen plan is split into. */
112
+ installmentCount?: number;
113
+ /**
114
+ * COHORT ENROLMENT ONLY: the SECOND consent, taken before instalment 1.
115
+ *
116
+ * Instalments 2..n are charged months later by a cron with nobody present, so
117
+ * the payer has to agree to be charged off-session. When Stripe needs that
118
+ * confirmed in the browser, `setupRequiresAction` is true and
119
+ * `setupClientSecret` is what confirms it. A checkout that ignores these takes
120
+ * the first payment and then silently fails every later one.
121
+ */
122
+ setupRequiresAction?: boolean;
123
+ setupClientSecret?: string | null;
104
124
  /**
105
125
  * When the reserved stock goes back on sale, ISO-8601 — the RESTARTED clock,
106
126
  * measured from the card form rather than from when the cart was assembled,
@@ -1385,8 +1405,21 @@ export type CohortPageData = {
1385
1405
  title: string;
1386
1406
  description: string | null;
1387
1407
  order: number;
1388
- releaseOffsetDays: number | null;
1389
- releaseAt: string | null;
1408
+ /**
1409
+ * The drip schedule and the answer it produces. Present only on the
1410
+ * ENROLLED read: the marketing payload gives a browser the shape of the
1411
+ * course, and a student its calendar.
1412
+ *
1413
+ * Render `locked` / `unlocksAt` / `lockReason`, not the raw offset. The
1414
+ * server computes them with the same expression the classroom uses, so a
1415
+ * renderer that re-derives the answer from `releaseOffsetDays` will
1416
+ * eventually disagree with the screen the student opens next.
1417
+ */
1418
+ releaseOffsetDays?: number | null;
1419
+ releaseAt?: string | null;
1420
+ locked?: boolean;
1421
+ unlocksAt?: string | null;
1422
+ lockReason?: "scheduled" | "cohort_not_started" | null;
1390
1423
  lessons?: Array<{
1391
1424
  id: string;
1392
1425
  title: string;
@@ -1402,12 +1435,21 @@ export type CohortPageData = {
1402
1435
  publishedAt: string | null;
1403
1436
  createdAt: string;
1404
1437
  }>;
1405
- sessions: Array<{
1438
+ /**
1439
+ * The cohort's scheduled calls. Renamed from `sessions` when
1440
+ * `cohort_sessions` was dropped for the unified `scheduled_calls` table.
1441
+ *
1442
+ * Carries NO join address on purpose. The endpoint behind this payload is
1443
+ * anonymous, so a `callPublicId` here would be a door into a live paid room.
1444
+ * A student joins from the classroom, where they are authenticated.
1445
+ */
1446
+ calls: Array<{
1406
1447
  id: string;
1407
1448
  title: string;
1408
1449
  description: string | null;
1409
- scheduledAt: string;
1410
- durationMinutes: number;
1450
+ startsAt: string;
1451
+ endsAt: string;
1452
+ timezone: string | null;
1411
1453
  status: string;
1412
1454
  }>;
1413
1455
  pricingPlans: Array<{
@@ -1653,6 +1695,18 @@ export type CourseAccessRecord = {
1653
1695
  * value as legacy (see `isLegacyCourseAccess`).
1654
1696
  */
1655
1697
  accountId?: string | null;
1698
+ /**
1699
+ * The cohort this enrolment is for, or `null` for a self-paced buy.
1700
+ *
1701
+ * Decides which MODULES the payload carries (a cohort holds its own cloned
1702
+ * copy) and, on the client, which live calls belong on this classroom page: a
1703
+ * student enrolled in two cohorts of one course has two grants and two
1704
+ * classrooms, and each must show only its own sessions.
1705
+ *
1706
+ * Optional on the type because sites built against an older API still get
1707
+ * payloads without the field.
1708
+ */
1709
+ cohortId?: string | null;
1656
1710
  };
1657
1711
 
1658
1712
  export type CourseLessonProgress = {
@@ -1683,6 +1737,25 @@ export type CourseModule = {
1683
1737
  description: string | null;
1684
1738
  order: number;
1685
1739
  lessons: CourseLesson[];
1740
+ /**
1741
+ * Drip state, on a cohort course. Optional because a self-paced course never
1742
+ * carries it and every site built before drip existed predates these fields.
1743
+ *
1744
+ * `locked` means the module has not been released to this student yet. The
1745
+ * server already strips `content` and `media` from a locked module's lessons,
1746
+ * so a renderer that ignores these fields is not a content leak. It is a
1747
+ * BROKEN SCREEN: the lessons render as normal, clickable, and open to
1748
+ * nothing. Render the lock.
1749
+ *
1750
+ * `unlocksAt` is the EFFECTIVE moment, after the cohort's start date and the
1751
+ * module's offset have been applied. It is null when the answer is not known
1752
+ * yet, which is what `lockReason: "cohort_not_started"` means: the creator
1753
+ * laid out the weeks before picking a start date. Say "opens when the cohort
1754
+ * starts" there rather than showing an empty date.
1755
+ */
1756
+ locked?: boolean;
1757
+ unlocksAt?: string | null;
1758
+ lockReason?: "scheduled" | "cohort_not_started" | null;
1686
1759
  };
1687
1760
 
1688
1761
  export type CourseAccessCourse = {
@@ -0,0 +1,180 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { useCohortPage } from "../../../data/queries/useCohortPage";
4
+ import { useCreateCohortEnrolment } from "../../../data/queries/useCohortEnrolment";
5
+ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
6
+ import { readAttributionRef } from "../../../utils/attribution";
7
+ import { rememberBookingSecret } from "../../../utils/bookingSecret";
8
+
9
+ export type CohortCheckoutStep = "plan" | "details" | "payment";
10
+
11
+ export interface UseCohortCheckoutOptions {
12
+ /**
13
+ * Stripe return URL for a PAID enrolment. Default
14
+ * `/i/cohorts/:cohortId/finalise?bookingId=...`.
15
+ *
16
+ * Put ONLY the bookingId in this URL. The booking's secret is stashed in
17
+ * sessionStorage by this hook and read back on the finalise page; putting it
18
+ * in the URL would hand it to history, referrers and shared links, which is
19
+ * the exact leak it exists to close.
20
+ */
21
+ finalisePath?: (cohortId: string, bookingId: string) => string;
22
+ /** Host-owned navigation on FREE completion (e.g. router navigate). */
23
+ onComplete?: (info: { cohortId: string; bookingId: string }) => void;
24
+ }
25
+
26
+ const errMessage = (e: unknown) =>
27
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
28
+
29
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
30
+
31
+ /**
32
+ * Headless cohort purchase: pick a plan, give buyer details, pay.
33
+ *
34
+ * THREE STEPS, NOT TWO, and the extra one is the point. A self-paced course has
35
+ * one price so its checkout opens on buyer details. A cohort is priced from its
36
+ * pricing plans, and choosing between "pay in full" and "three instalments" is
37
+ * the decision the buyer came to make, so it comes first and is never guessed.
38
+ * The default plan is preselected; nothing is bought until they say so.
39
+ *
40
+ * NO COUPON FIELD, deliberately. The cohort checkout takes a `planId` and
41
+ * nothing else that touches money: the plan IS the price. That is what keeps
42
+ * instalments 2..n, charged months later by a cron, equal to what was
43
+ * advertised.
44
+ */
45
+ export function useCohortCheckout(cohortId?: string, opts: UseCohortCheckoutOptions = {}) {
46
+ const { user } = usePublicAuth();
47
+ const { data: page, isLoading } = useCohortPage(cohortId);
48
+ const createEnrolment = useCreateCohortEnrolment(cohortId);
49
+ const flow = usePaymentFlow({ path: `/public/cohorts/${cohortId}/start-payment`, autoStart: false });
50
+
51
+ const plans = useMemo(() => page?.data?.pricingPlans ?? [], [page]);
52
+
53
+ const [step, setStep] = useState<CohortCheckoutStep>("plan");
54
+ const [planId, setPlanId] = useState<string | undefined>(undefined);
55
+ const [firstName, setFirstName] = useState(user?.firstName ?? "");
56
+ const [lastName, setLastName] = useState(user?.lastName ?? "");
57
+ const [email, setEmail] = useState(user?.email ?? "");
58
+ const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
59
+ const [returnUrl, setReturnUrl] = useState("");
60
+ const [error, setError] = useState<string | null>(null);
61
+
62
+ /**
63
+ * The per-booking credential from `createEnrolment`. A ref, not state, so it
64
+ * never triggers a render and never lands in a dependency array. The backend
65
+ * returns it once and cannot reissue it.
66
+ */
67
+ const bookingSecretRef = useRef<string | undefined>(undefined);
68
+
69
+ /**
70
+ * The creator's own default wins, and the first plan is only a fallback for a
71
+ * cohort whose plans predate `isDefault`. Preselecting nothing would make the
72
+ * commonest case ("one plan, pay in full") an extra click for no decision.
73
+ */
74
+ const selectedPlan = useMemo(() => {
75
+ if (planId) return plans.find((p) => p.id === planId);
76
+ return plans.find((p) => p.isDefault) ?? plans[0];
77
+ }, [plans, planId]);
78
+
79
+ const finalisePath = opts.finalisePath ?? ((id, bookingId) => `/i/cohorts/${id}/finalise?bookingId=${bookingId}`);
80
+
81
+ const choosePlan = (id: string) => {
82
+ setError(null);
83
+ setPlanId(id);
84
+ setStep("details");
85
+ };
86
+
87
+ const enrol = async () => {
88
+ setError(null);
89
+ if (!cohortId) return;
90
+ if (!selectedPlan) {
91
+ setError("Choose a plan before continuing.");
92
+ return;
93
+ }
94
+ if (!firstName.trim() || !lastName.trim() || !EMAIL.test(email)) {
95
+ setError("Please enter your name and a valid email.");
96
+ return;
97
+ }
98
+
99
+ try {
100
+ const created = await createEnrolment.mutateAsync({
101
+ planId: selectedPlan.id,
102
+ email,
103
+ firstName,
104
+ lastName,
105
+ questionnaire,
106
+ attributionRefId: readAttributionRef() ?? undefined,
107
+ });
108
+ bookingSecretRef.current = created.bookingSecret;
109
+ // Written the instant it arrives: this is the only time the server will
110
+ // ever hand it over, and the finalise page (post-redirect) has no other
111
+ // way to get it.
112
+ rememberBookingSecret(created.bookingId, created.bookingSecret);
113
+
114
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
115
+ const ru = `${origin}${finalisePath(cohortId, created.bookingId)}`;
116
+ setReturnUrl(ru);
117
+
118
+ // A zero-priced plan confirms on creation and the access email fires from
119
+ // the server, so there is nothing to pay and nothing to wait for.
120
+ if (created.isFree) {
121
+ if (opts.onComplete) opts.onComplete({ cohortId, bookingId: created.bookingId });
122
+ else if (typeof window !== "undefined") window.location.href = ru;
123
+ return;
124
+ }
125
+
126
+ const result = await flow.start({
127
+ bookingId: created.bookingId,
128
+ bookingSecret: bookingSecretRef.current,
129
+ returnUrl: ru,
130
+ });
131
+ if (result.provider && result.provider !== "stripe") return;
132
+ setStep("payment");
133
+ } catch (e) {
134
+ setError(errMessage(e));
135
+ }
136
+ };
137
+
138
+ return {
139
+ cohort: page?.data?.cohort,
140
+ course: page?.data?.course,
141
+ isLoading,
142
+ plans,
143
+ selectedPlan,
144
+ planId: selectedPlan?.id,
145
+ choosePlan,
146
+ step,
147
+ setStep,
148
+ firstName,
149
+ setFirstName,
150
+ lastName,
151
+ setLastName,
152
+ email,
153
+ setEmail,
154
+ questionnaire,
155
+ setQuestionnaire,
156
+ enrol,
157
+ clientSecret: flow.clientSecret,
158
+ /** Paystack: the started inline-checkout session and the way back into it. */
159
+ isPaystack: flow.isPaystack,
160
+ canOpenPaystack: flow.canOpenPaystack,
161
+ openPaystackCheckout: flow.openPaystackCheckout,
162
+ /** The whole plan. */
163
+ totalAmount: flow.result?.totalAmount ?? (selectedPlan ? Number(selectedPlan.totalAmount) : undefined),
164
+ /** What THIS charge takes: the first instalment, or the whole plan. */
165
+ installmentAmount: flow.result?.installmentAmount,
166
+ installmentCount: selectedPlan?.installmentCount ?? 1,
167
+ /**
168
+ * Stripe needs a second confirmation before instalment 1 on a multi-payment
169
+ * plan: the consent to be charged again off-session. Render it, or the
170
+ * later instalments have no payment method to hit.
171
+ */
172
+ setupRequiresAction: flow.result?.setupRequiresAction ?? false,
173
+ setupClientSecret: flow.result?.setupClientSecret ?? null,
174
+ /** Authoritative sales-tax quote from start-payment (display only). */
175
+ taxQuote: flow.result?.taxQuote ?? null,
176
+ returnUrl,
177
+ isProcessing: createEnrolment.isPending || flow.isStarting,
178
+ error,
179
+ };
180
+ }
@@ -48,6 +48,17 @@ export interface UseMemberCallsOptions {
48
48
  now?: Date;
49
49
  /** Monday-first. The repo's `utils/recurrence` fixes the same convention. */
50
50
  weekStartsOn?: 0 | 1;
51
+ /**
52
+ * Narrow the month to ONE course, for a calendar rendered inside a classroom
53
+ * rather than in the members area.
54
+ *
55
+ * Absent on the members calendar, which is the viewer's whole schedule and
56
+ * must stay so. Present, the server intersects it with the audience keys it
57
+ * already resolved for this caller, so it can only ever remove rows.
58
+ */
59
+ courseId?: string | null;
60
+ /** The same, for one cohort. A student in two cohorts of one course sees only this one's sessions. */
61
+ cohortId?: string | null;
51
62
  }
52
63
 
53
64
  const dayKey = (date: Date): string => date.toLocaleDateString("en-CA");
@@ -90,6 +101,8 @@ export function useMemberCalls(options: UseMemberCallsOptions = {}) {
90
101
  to: gridEnd,
91
102
  limit: options.limit,
92
103
  enabled: options.enabled,
104
+ courseId: options.courseId,
105
+ cohortId: options.cohortId,
93
106
  });
94
107
 
95
108
  const days: MemberCalendarDay[] = useMemo(() => {