@tribe-nest/forge 3.50.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/i18n/de.json +45 -0
- package/src/i18n/en.json +45 -0
- package/src/index.ts +1 -0
- package/src/types/models.ts +20 -0
- package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
- package/src/ui/index.ts +7 -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 +16 -0
- package/src/ui/styled/CourseDetail.tsx +4 -4
- package/src/ui/styled/EventDetail.tsx +4 -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/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 +2 -2
- 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
|
+
}
|
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",
|
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",
|
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,
|
|
@@ -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
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -197,6 +197,13 @@ export {
|
|
|
197
197
|
export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/ConfirmSubscription";
|
|
198
198
|
export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
|
|
199
199
|
export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
|
|
200
|
+
export { CohortCheckout, type CohortCheckoutProps } from "./styled/CohortCheckout";
|
|
201
|
+
export { CohortConfirmation, type CohortConfirmationProps } from "./styled/CohortConfirmation";
|
|
202
|
+
export {
|
|
203
|
+
useCohortCheckout,
|
|
204
|
+
type UseCohortCheckoutOptions,
|
|
205
|
+
type CohortCheckoutStep,
|
|
206
|
+
} from "./headless/cohort/useCohortCheckout";
|
|
200
207
|
export { CourseAccess, type CourseAccessProps } from "./styled/CourseAccess";
|
|
201
208
|
// A student's live calls, scoped to the course or cohort the page is standing
|
|
202
209
|
// on. A thin wrapper over `MemberCallsCalendar`, not a second calendar.
|
|
@@ -157,7 +157,7 @@ export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostPr
|
|
|
157
157
|
|
|
158
158
|
{/* `rich-text` = shared baseline content styles (WYSIWYG with the editor),
|
|
159
159
|
bundled with Forge via the ./rich-text.css import above. */}
|
|
160
|
-
<div className="rich-text" dangerouslySetInnerHTML={{ __html: post.content || "" }} />
|
|
160
|
+
<div className="fg:rich-text" dangerouslySetInnerHTML={{ __html: post.content || "" }} />
|
|
161
161
|
</article>
|
|
162
162
|
);
|
|
163
163
|
}
|
|
@@ -124,7 +124,7 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
124
124
|
>
|
|
125
125
|
{/* Channel list: full-width on mobile, fixed rail on desktop */}
|
|
126
126
|
<aside
|
|
127
|
-
className={`${showSidebar ? "flex" : "hidden"} md:flex w-full md:w-[260px] flex-col`}
|
|
127
|
+
className={`${showSidebar ? "fg:flex" : "fg:hidden"} fg:md:flex fg:w-full fg:md:w-[260px] fg:flex-col`}
|
|
128
128
|
style={{ borderRight: border, overflowY: "auto" }}
|
|
129
129
|
>
|
|
130
130
|
<div
|
|
@@ -174,7 +174,7 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
174
174
|
</aside>
|
|
175
175
|
|
|
176
176
|
{/* Message panel */}
|
|
177
|
-
<section className={`${showSidebar ? "hidden" : "flex"} md:flex flex-1 flex-col min-w-0`}>
|
|
177
|
+
<section className={`${showSidebar ? "fg:hidden" : "fg:flex"} fg:md:flex fg:flex-1 fg:flex-col fg:min-w-0`}>
|
|
178
178
|
{room.selectedChannel ? (
|
|
179
179
|
<MessagePanel room={room} onBack={() => setShowSidebar(true)} border={border} />
|
|
180
180
|
) : (
|