@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.
- package/package.json +2 -2
- package/src/data/queries/useCohortEnrolment.ts +87 -0
- package/src/data/queries/useMembershipCalls.ts +58 -8
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/index.ts +1 -0
- package/src/types/models.ts +78 -5
- package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +13 -0
- package/src/ui/index.ts +10 -0
- package/src/ui/styled/BlogPost.tsx +1 -1
- package/src/ui/styled/ChatRoom.tsx +2 -2
- package/src/ui/styled/Checkout.tsx +125 -125
- package/src/ui/styled/CoachingBooking.tsx +1 -1
- package/src/ui/styled/CoachingDetail.tsx +4 -4
- package/src/ui/styled/CohortCheckout.tsx +450 -0
- package/src/ui/styled/CohortConfirmation.tsx +259 -0
- package/src/ui/styled/CohortPage.tsx +31 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/CourseDetail.tsx +4 -4
- package/src/ui/styled/EventDetail.tsx +4 -4
- package/src/ui/styled/FormRenderer.tsx +14 -4
- package/src/ui/styled/PaystackPayButton.tsx +1 -1
- package/src/ui/styled/PodcastEpisode.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +4 -4
- package/src/ui/styled/_tests/CohortCheckout.spec.tsx +232 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
- package/src/ui/styled/_tests/packagedCssIsCurrent.spec.ts +43 -0
- package/src/ui/styled/_tests/utilityPrefix.spec.ts +156 -0
- package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +1 -1
- package/src/ui/styled/chat/ChatThreadPanel.tsx +1 -1
- package/src/ui/styled/forge-utilities.css +463 -731
- package/src/ui/styled/members/MemberCallsCalendar.tsx +21 -3
- package/src/ui/styled/members/MemberHomeLayout.tsx +4 -4
- package/src/ui/styled/members/MemberPostsFeed.tsx +1 -1
- package/src/ui/styled/members/PostCollectionsGrid.tsx +1 -1
- 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.
|
|
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
|
|
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
|
|
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
|
|
10
|
-
* `useMyTickets` and `useMyBookings`
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
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(() => {
|