@tribe-nest/forge 3.21.0 → 3.23.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 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -17,9 +17,30 @@ export interface AddToCalendarProps extends AddToCalendarInput {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365,
|
|
21
|
+
* plus the two Apple-shaped options once the host supplies the platform's
|
|
22
|
+
* `.ics` address.
|
|
23
|
+
*
|
|
24
|
+
* ## Why Apple needs two entries and the others need none
|
|
25
|
+
*
|
|
26
|
+
* Google and Microsoft publish TEMPLATE URLs: the whole event travels in the
|
|
27
|
+
* query string and no server is involved. Apple publishes nothing of the kind —
|
|
28
|
+
* Calendar takes a FILE. So for as long as this component had no `.ics` to
|
|
29
|
+
* point at, an iPhone user had no option at all, which is precisely the state
|
|
30
|
+
* the product shipped in.
|
|
31
|
+
*
|
|
32
|
+
* - **Apple Calendar** → `webcal://`. The scheme is what iOS and macOS route
|
|
33
|
+
* to the Calendar app; it must be a plain same-window navigation, so it
|
|
34
|
+
* carries neither `target="_blank"` (the OS handler, not a tab) nor
|
|
35
|
+
* `download` (there is no file to save — the OS is opening an app).
|
|
36
|
+
* - **Download .ics** → the https address, with `download`. Outlook desktop,
|
|
37
|
+
* Thunderbird, Android calendar apps and anyone who would simply rather have
|
|
38
|
+
* the file.
|
|
39
|
+
*
|
|
40
|
+
* Both are offered because `webcal:` has no handler on much of the desktop web,
|
|
41
|
+
* and a lone Apple button on Windows is a dead link. Neither appears at all
|
|
42
|
+
* when the host passes no URL — a site pinned to an older API build degrades to
|
|
43
|
+
* exactly the three-provider row it had before.
|
|
23
44
|
*
|
|
24
45
|
* Purely presentational: every URL comes from `useAddToCalendar`. Renders
|
|
25
46
|
* nothing at all when the start time is missing or unparseable.
|
|
@@ -38,12 +59,14 @@ export function AddToCalendar({
|
|
|
38
59
|
|
|
39
60
|
const compact = variant === "compact";
|
|
40
61
|
|
|
41
|
-
const options: { key: string; text: string; href: string; download?: boolean }[] = [
|
|
42
|
-
{ key: "google", text: "Google", href: links.google },
|
|
43
|
-
{ key: "outlook", text: "Outlook", href: links.outlook },
|
|
44
|
-
{ key: "office365", text: "Microsoft 365", href: links.office365 },
|
|
62
|
+
const options: { key: string; text: string; href: string; download?: boolean; newTab?: boolean }[] = [
|
|
63
|
+
{ key: "google", text: "Google", href: links.google, newTab: true },
|
|
64
|
+
{ key: "outlook", text: "Outlook", href: links.outlook, newTab: true },
|
|
65
|
+
{ key: "office365", text: "Microsoft 365", href: links.office365, newTab: true },
|
|
45
66
|
];
|
|
46
|
-
|
|
67
|
+
// Apple first among the file-based options: it is the one a phone will use.
|
|
68
|
+
if (links.webcal) options.push({ key: "apple", text: "Apple Calendar", href: links.webcal });
|
|
69
|
+
if (links.ics) options.push({ key: "ics", text: "Download .ics", href: links.ics, download: true });
|
|
47
70
|
|
|
48
71
|
const chip: React.CSSProperties = {
|
|
49
72
|
display: "inline-flex",
|
|
@@ -89,8 +112,9 @@ export function AddToCalendar({
|
|
|
89
112
|
<a
|
|
90
113
|
key={o.key}
|
|
91
114
|
href={o.href}
|
|
92
|
-
|
|
93
|
-
rel
|
|
115
|
+
// `webcal:` hands off to the OS — a new tab would leave a blank one
|
|
116
|
+
// behind, and `rel` is meaningless for a non-http scheme.
|
|
117
|
+
{...(o.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
94
118
|
data-testid={`add-to-calendar-${o.key}`}
|
|
95
119
|
{...(o.download ? { download: "" } : {})}
|
|
96
120
|
style={chip}
|
|
@@ -77,7 +77,7 @@ export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerP
|
|
|
77
77
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
78
78
|
<div style={{ fontWeight: 600, fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
79
79
|
{a.currentTrack?.title ?? "Nothing playing"}
|
|
80
|
-
{a.currentTrack?.artist ? `
|
|
80
|
+
{a.currentTrack?.artist ? ` · ${a.currentTrack.artist}` : ""}
|
|
81
81
|
</div>
|
|
82
82
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, opacity: 0.8 }}>
|
|
83
83
|
<span>{fmtTime(a.currentTime)}</span>
|
|
@@ -105,7 +105,7 @@ export function BundleConfirmation({
|
|
|
105
105
|
<WarnSeal icon={<Clock size={28} />} />
|
|
106
106
|
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment received</h1>
|
|
107
107
|
<p style={{ opacity: 0.75, marginBottom: 20 }}>
|
|
108
|
-
Your payment went through and part of your order is confirmed. We're still finishing the rest
|
|
108
|
+
Your payment went through and part of your order is confirmed. We're still finishing the rest. It
|
|
109
109
|
will arrive by email shortly, with nothing more to pay.
|
|
110
110
|
</p>
|
|
111
111
|
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
@@ -12,6 +12,8 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
|
12
12
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
13
13
|
import { Loading } from "./Loading";
|
|
14
14
|
import { CartLineOptions } from "./CartLineOptions";
|
|
15
|
+
import { HoldNotice } from "./HoldNotice";
|
|
16
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
15
17
|
|
|
16
18
|
export interface CheckoutProps {
|
|
17
19
|
/** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
|
|
@@ -797,11 +799,16 @@ export function Checkout({
|
|
|
797
799
|
|
|
798
800
|
// ── Payment section ───────────────────────────────────────────────────────────
|
|
799
801
|
/**
|
|
800
|
-
* The payment stage. The order +
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
* so a coupon that re-issues the intent remounts the form against the
|
|
802
|
+
* The payment stage. The order + charge are created by `useCheckout` (on
|
|
803
|
+
* entering this stage); here we only render the resolved provider UI: Stripe
|
|
804
|
+
* via the registered `<ForgePaymentProvider>` renderer, Paystack via a pay
|
|
805
|
+
* button that opens its inline modal ON THIS PAGE. Re-keyed on the client
|
|
806
|
+
* secret so a coupon that re-issues the intent remounts the form against the
|
|
807
|
+
* latest one.
|
|
808
|
+
*
|
|
809
|
+
* The buyer never leaves the creator's site on either provider now. Nothing
|
|
810
|
+
* happens on mount: the modal is opened by the button, so a buyer still reading
|
|
811
|
+
* the summary is not interrupted, and one who closes it can press it again.
|
|
805
812
|
*/
|
|
806
813
|
function PaymentSection({
|
|
807
814
|
checkout,
|
|
@@ -811,18 +818,15 @@ function PaymentSection({
|
|
|
811
818
|
onBack?: () => void;
|
|
812
819
|
}) {
|
|
813
820
|
const theme = useForgeTheme();
|
|
814
|
-
const { formatCurrency } = useFormatCurrency();
|
|
815
821
|
const renderPay = usePaymentRenderer();
|
|
816
822
|
const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
|
|
823
|
+
const { canOpenPaystack, openPaystackCheckout } = checkout;
|
|
824
|
+
const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold } = checkout;
|
|
817
825
|
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
if (isPaystack && paymentSecret && typeof window !== "undefined") {
|
|
823
|
-
window.location.href = paymentSecret;
|
|
824
|
-
}
|
|
825
|
-
}, [isPaystack, paymentSecret]);
|
|
826
|
+
// "There is something to pay with", whichever provider answered: Stripe's
|
|
827
|
+
// secret or Paystack's session. Used only to decide whether the preparing
|
|
828
|
+
// spinner is still honest.
|
|
829
|
+
const hasCharge = isPaystack ? canOpenPaystack : !!paymentSecret;
|
|
826
830
|
|
|
827
831
|
const backButton = onBack ? (
|
|
828
832
|
<button
|
|
@@ -880,25 +884,31 @@ function PaymentSection({
|
|
|
880
884
|
</p>
|
|
881
885
|
)}
|
|
882
886
|
|
|
883
|
-
{
|
|
887
|
+
{/* The reservation — a countdown while it runs, and at zero (or on a 409)
|
|
888
|
+
the lapsed state with the retry that re-acquires the same units.
|
|
889
|
+
Renders nothing at all when no hold was taken, which is the ordinary
|
|
890
|
+
case for the profiles that have inventory holds switched off. */}
|
|
891
|
+
<HoldNotice
|
|
892
|
+
hold={hold}
|
|
893
|
+
expiredMessage={holdExpiredError || undefined}
|
|
894
|
+
onRetry={retryHold}
|
|
895
|
+
isRetrying={isRetryingHold}
|
|
896
|
+
secondaryAction={onBack ? { label: "Change your order", onClick: onBack } : undefined}
|
|
897
|
+
/>
|
|
898
|
+
|
|
899
|
+
{/* Suppressed once the server has refused: there is no intent coming, so a
|
|
900
|
+
"Preparing payment…" spinner would sit there forever under a notice
|
|
901
|
+
that has already explained what happened. */}
|
|
902
|
+
{isCreatingOrder && !hasCharge && !isHoldExpired && <Loading label="Preparing payment…" size={22} />}
|
|
884
903
|
|
|
885
904
|
{isPaystack
|
|
886
|
-
?
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
className="w-full px-4 py-3 font-semibold"
|
|
894
|
-
style={{
|
|
895
|
-
backgroundColor: theme.colors.primary,
|
|
896
|
-
color: readableTextOn(theme.colors.primary),
|
|
897
|
-
borderRadius: `${theme.cornerRadius}px`,
|
|
898
|
-
}}
|
|
899
|
-
>
|
|
900
|
-
{`Pay ${formatCurrency(chargedAmount, chargedCurrency || undefined)} Now`}
|
|
901
|
-
</button>
|
|
905
|
+
? canOpenPaystack && (
|
|
906
|
+
<PaystackPayButton
|
|
907
|
+
onPay={openPaystackCheckout}
|
|
908
|
+
amount={chargedAmount}
|
|
909
|
+
currency={chargedCurrency || undefined}
|
|
910
|
+
disabled={isHoldExpired}
|
|
911
|
+
/>
|
|
902
912
|
)
|
|
903
913
|
: paymentSecret &&
|
|
904
914
|
renderPay && (
|
|
@@ -958,7 +968,7 @@ function ShippingRateSelector({
|
|
|
958
968
|
>
|
|
959
969
|
<div>
|
|
960
970
|
<span className="text-sm font-medium" style={{ color: theme.colors.text }}>
|
|
961
|
-
{group.rates[0].carrier}
|
|
971
|
+
{group.rates[0].carrier} · {group.rates[0].service}
|
|
962
972
|
</span>
|
|
963
973
|
{group.rates[0].estimatedDays != null && (
|
|
964
974
|
<span className="text-xs ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
@@ -215,10 +215,10 @@ function OrderConfirmation({
|
|
|
215
215
|
? `We’ve emailed your receipt to ${email}.`
|
|
216
216
|
: "Your receipt is on its way."
|
|
217
217
|
: isFailed
|
|
218
|
-
? "Your payment didn’t go through
|
|
218
|
+
? "Your payment didn’t go through, so you haven’t been charged. You can try again."
|
|
219
219
|
: gaveUp
|
|
220
220
|
? "This is taking longer than usual. We’ll email your receipt as soon as it clears."
|
|
221
|
-
: "Hang tight
|
|
221
|
+
: "Hang tight: this only takes a few seconds.";
|
|
222
222
|
|
|
223
223
|
return (
|
|
224
224
|
<ConfirmationStage>
|
|
@@ -6,6 +6,7 @@ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
7
|
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
8
|
import { Loading } from "./Loading";
|
|
9
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
9
10
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
10
11
|
import { buttonStyle } from "./Button";
|
|
11
12
|
import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
|
|
@@ -283,7 +284,7 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
|
|
|
283
284
|
}
|
|
284
285
|
})();
|
|
285
286
|
|
|
286
|
-
const weekLabel = `${c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
|
287
|
+
const weekLabel = `${c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} to ${c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
|
287
288
|
const navButton = (disabled: boolean): CSSProperties => ({
|
|
288
289
|
padding: 8,
|
|
289
290
|
borderRadius: theme.cornerRadius,
|
|
@@ -545,7 +546,15 @@ function PaymentStep({
|
|
|
545
546
|
the money moves, and a policy read three clicks earlier is not a policy
|
|
546
547
|
read before committing. */}
|
|
547
548
|
<CancellationTerms terms={c.cancellation} style={{ marginBottom: 16 }} />
|
|
548
|
-
{
|
|
549
|
+
{/* Paystack needs no registered renderer at all (it has its own modal),
|
|
550
|
+
so it is checked BEFORE the "not configured" branch. */}
|
|
551
|
+
{c.isPaystack ? (
|
|
552
|
+
c.canOpenPaystack ? (
|
|
553
|
+
<PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(total)}`} />
|
|
554
|
+
) : (
|
|
555
|
+
<p style={{ opacity: 0.7 }}>Preparing payment…</p>
|
|
556
|
+
)
|
|
557
|
+
) : !renderPayment ? (
|
|
549
558
|
<p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
|
|
550
559
|
) : c.clientSecret ? (
|
|
551
560
|
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: total, mode: "redirect" })
|
|
@@ -79,7 +79,7 @@ export function CoachingConfirmation({
|
|
|
79
79
|
<ConfirmationStage>
|
|
80
80
|
<ConfirmationLostCheckout
|
|
81
81
|
title="We can’t confirm your booking in this browser"
|
|
82
|
-
body="Confirming a booking needs the checkout details saved in the tab you started in
|
|
82
|
+
body="Confirming a booking needs the checkout details saved in the tab you started in, and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your confirmation email, so check your inbox before booking again. Otherwise, pick a new time and start over."
|
|
83
83
|
restartPath={restartPath ?? `/i/coaching/${slug}`}
|
|
84
84
|
restartLabel="Pick a new time"
|
|
85
85
|
/>
|
|
@@ -123,7 +123,7 @@ export function CoachingConfirmation({
|
|
|
123
123
|
style={{ fontSize: 15, lineHeight: 1.5, color: alpha(text, 0.7), margin: "0 auto", maxWidth: 340, animationDelay: "0.3s" }}
|
|
124
124
|
>
|
|
125
125
|
{confirmed
|
|
126
|
-
? `Nice one${data?.firstName ? `, ${data.firstName}` : ""}
|
|
126
|
+
? `Nice one${data?.firstName ? `, ${data.firstName}` : ""}. Your session is confirmed. We’ve sent the details to ${data?.email ?? "your inbox"}.`
|
|
127
127
|
: `Your booking is currently ${data?.status ?? "unresolved"}. If you were charged, reach out and we’ll sort it right away.`}
|
|
128
128
|
</p>
|
|
129
129
|
</div>
|
|
@@ -139,7 +139,7 @@ export function CoachingConfirmation({
|
|
|
139
139
|
<ConfirmationRow label="Session" value={formatSession(data.sessionStartTime)} accent strong />
|
|
140
140
|
)}
|
|
141
141
|
{data.durationMinutes ? <ConfirmationRow label="Duration" value={`${data.durationMinutes} minutes`} /> : null}
|
|
142
|
-
<ConfirmationRow label="Guest" value={`${data.firstName} ${data.lastName}`.trim() || "
|
|
142
|
+
<ConfirmationRow label="Guest" value={`${data.firstName} ${data.lastName}`.trim() || "Not given"} />
|
|
143
143
|
<ConfirmationRow label="Email" value={data.email} />
|
|
144
144
|
<ConfirmationRow label="Reference" value={`#${data.id.slice(0, 8).toUpperCase()}`} mono />
|
|
145
145
|
{Number(data.taxAmount || 0) > 0 && !data.taxInclusive && (
|
|
@@ -173,6 +173,10 @@ export function CoachingConfirmation({
|
|
|
173
173
|
start={data.sessionStartTime}
|
|
174
174
|
end={data.sessionEndTime}
|
|
175
175
|
defaultDurationMinutes={data.durationMinutes ?? undefined}
|
|
176
|
+
// The finalise response carries these only for a CONFIRMED
|
|
177
|
+
// booking, which is the same condition this block renders under.
|
|
178
|
+
icsUrl={data.calendarUrl}
|
|
179
|
+
webcalUrl={data.calendarWebcalUrl}
|
|
176
180
|
style={{ marginTop: 18 }}
|
|
177
181
|
/>
|
|
178
182
|
)}
|
|
@@ -34,7 +34,7 @@ export function CohortPage({ cohortId }: CohortPageProps) {
|
|
|
34
34
|
{(cohort.startDate || cohort.endDate) && (
|
|
35
35
|
<p style={{ opacity: 0.7, marginBottom: 16 }}>
|
|
36
36
|
{cohort.startDate ? formatDate(cohort.startDate) : "TBD"}
|
|
37
|
-
{cohort.endDate ? `
|
|
37
|
+
{cohort.endDate ? ` to ${formatDate(cohort.endDate)}` : ""}
|
|
38
38
|
</p>
|
|
39
39
|
)}
|
|
40
40
|
{cohort.description && <p style={{ marginBottom: 24, lineHeight: 1.6 }}>{cohort.description}</p>}
|
|
@@ -11,7 +11,7 @@ export interface ContactFormProps {
|
|
|
11
11
|
/** One-line themed website contact form, built on `useContactForm`. */
|
|
12
12
|
export function ContactForm({
|
|
13
13
|
buttonText = "Send Message",
|
|
14
|
-
successMessage = "Message sent
|
|
14
|
+
successMessage = "Message sent. We'll be in touch.",
|
|
15
15
|
showJoinEmailList = true,
|
|
16
16
|
}: ContactFormProps) {
|
|
17
17
|
const theme = useForgeTheme();
|
|
@@ -6,6 +6,7 @@ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
7
|
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
8
|
import { Loading } from "./Loading";
|
|
9
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
9
10
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
10
11
|
import { buttonStyle } from "./Button";
|
|
11
12
|
import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
|
|
@@ -354,7 +355,15 @@ function PaymentStep({
|
|
|
354
355
|
</span>
|
|
355
356
|
</div>
|
|
356
357
|
</div>
|
|
357
|
-
{
|
|
358
|
+
{/* Paystack needs no registered renderer at all (it has its own modal),
|
|
359
|
+
so it is checked BEFORE the "not configured" branch. */}
|
|
360
|
+
{c.isPaystack ? (
|
|
361
|
+
c.canOpenPaystack ? (
|
|
362
|
+
<PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(amount)}`} />
|
|
363
|
+
) : (
|
|
364
|
+
<p style={{ opacity: 0.7 }}>Preparing payment…</p>
|
|
365
|
+
)
|
|
366
|
+
) : !renderPayment ? (
|
|
358
367
|
<p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
|
|
359
368
|
) : c.clientSecret ? (
|
|
360
369
|
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount, mode: "redirect" })
|
|
@@ -71,7 +71,7 @@ export function CourseConfirmation({
|
|
|
71
71
|
<ConfirmationStage>
|
|
72
72
|
<ConfirmationLostCheckout
|
|
73
73
|
title="We can’t confirm your enrollment in this browser"
|
|
74
|
-
body="Confirming an enrollment needs the checkout details saved in the tab you started in
|
|
74
|
+
body="Confirming an enrollment needs the checkout details saved in the tab you started in, and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your enrollment email, so check your inbox before paying again. Otherwise, start the enrollment over."
|
|
75
75
|
restartPath={restartPath ?? `/i/courses/${slug}`}
|
|
76
76
|
restartLabel="Start over"
|
|
77
77
|
/>
|
|
@@ -177,7 +177,7 @@ export function CourseConfirmation({
|
|
|
177
177
|
</div>
|
|
178
178
|
)}
|
|
179
179
|
<dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 12, columnGap: 16, margin: 0 }}>
|
|
180
|
-
<ConfirmationRow label="Student" value={`${data.firstName} ${data.lastName}`.trim() || "
|
|
180
|
+
<ConfirmationRow label="Student" value={`${data.firstName} ${data.lastName}`.trim() || "Not given"} />
|
|
181
181
|
<ConfirmationRow label="Reference" value={`#${data.id.slice(0, 8).toUpperCase()}`} mono />
|
|
182
182
|
{Number(data.taxAmount || 0) > 0 && !data.taxInclusive && (
|
|
183
183
|
<ConfirmationRow label="Tax" value={fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PublicCourse } from "../../types/models";
|
|
2
2
|
import { useGetCourse } from "../../data/queries/useCourses";
|
|
3
|
+
import { MembershipGateNotice } from "./MembershipGateNotice";
|
|
3
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
6
|
import { PriceDisplay } from "../format/PriceDisplay";
|
|
@@ -23,6 +24,10 @@ export interface CourseDetailProps {
|
|
|
23
24
|
finalisePath?: (slug: string, bookingId: string) => string;
|
|
24
25
|
/** Host-owned navigation on FREE completion (forwarded to the enroll flow). */
|
|
25
26
|
onComplete?: (info: { slug: string; bookingId: string }) => void;
|
|
27
|
+
/** Where a members-only course sends a signed-OUT visitor. Default `/login`. */
|
|
28
|
+
loginPath?: string;
|
|
29
|
+
/** Where a members-only course sends a signed-in NON-member. Default `/membership`. */
|
|
30
|
+
membershipPath?: string;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function CourseDetail({
|
|
@@ -33,6 +38,8 @@ export function CourseDetail({
|
|
|
33
38
|
initialCourse,
|
|
34
39
|
finalisePath,
|
|
35
40
|
onComplete,
|
|
41
|
+
loginPath,
|
|
42
|
+
membershipPath,
|
|
36
43
|
}: CourseDetailProps) {
|
|
37
44
|
const t = useThemeTokens();
|
|
38
45
|
const fmt = useAmountFormatter(formatAmount);
|
|
@@ -42,6 +49,11 @@ export function CourseDetail({
|
|
|
42
49
|
if (isLoading) return <Loading fullPage />;
|
|
43
50
|
if (!course) return <p>Course not found.</p>;
|
|
44
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Members-only course (S.4). Announced by the detail read, which is why the
|
|
54
|
+
* page can say so BEFORE the buyer opens a checkout that would refuse them.
|
|
55
|
+
*/
|
|
56
|
+
const isGated = !!course.membershipGate && !course.membershipGate.allowed;
|
|
45
57
|
const cover = course.media?.find((m) => m.type === "image")?.url ?? course.media?.[0]?.url;
|
|
46
58
|
const introVideo = course.media?.find((m) => m.type === "video")?.url;
|
|
47
59
|
const modules = course.modules ?? [];
|
|
@@ -129,7 +141,24 @@ export function CourseDetail({
|
|
|
129
141
|
<PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
|
|
130
142
|
)}
|
|
131
143
|
</span>
|
|
132
|
-
|
|
144
|
+
{/*
|
|
145
|
+
A live gate locks the course even for someone who already bought it —
|
|
146
|
+
"this course is part of Gold" is a CONDITION for access, not a
|
|
147
|
+
one-off unlock — so a lapsed member lands here too and needs the way
|
|
148
|
+
back in, not a purchase button that will be refused.
|
|
149
|
+
*/}
|
|
150
|
+
{isGated ? (
|
|
151
|
+
<MembershipGateNotice
|
|
152
|
+
gate={course.membershipGate}
|
|
153
|
+
variant="card"
|
|
154
|
+
itemLabel="This course"
|
|
155
|
+
loginPath={loginPath}
|
|
156
|
+
membershipPath={membershipPath}
|
|
157
|
+
style={{ padding: 0, border: "none", background: "transparent" }}
|
|
158
|
+
/>
|
|
159
|
+
) : (
|
|
160
|
+
<CourseCheckout slug={slug} formatAmount={fmt} finalisePath={finalisePath} onComplete={onComplete} />
|
|
161
|
+
)}
|
|
133
162
|
</aside>
|
|
134
163
|
</div>
|
|
135
164
|
|
|
@@ -129,7 +129,7 @@ export function DiscountCodeField({
|
|
|
129
129
|
<div>
|
|
130
130
|
{applied.map((c) => (
|
|
131
131
|
<p key={c.code} style={{ margin: 0, fontSize: 14, fontWeight: 600, color: theme.colors.primary }}>
|
|
132
|
-
{c.code}
|
|
132
|
+
{c.code} · {fmt(c.discountAmount)} off
|
|
133
133
|
</p>
|
|
134
134
|
))}
|
|
135
135
|
</div>
|
|
@@ -39,7 +39,7 @@ export function EmailListForm({
|
|
|
39
39
|
if (f.isSuccess) {
|
|
40
40
|
return (
|
|
41
41
|
<div style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "center", padding: 8 }}>
|
|
42
|
-
{f.requiresConfirmation ? "Almost there
|
|
42
|
+
{f.requiresConfirmation ? "Almost there: check your email to confirm." : successMessage}
|
|
43
43
|
</div>
|
|
44
44
|
);
|
|
45
45
|
}
|
|
@@ -148,6 +148,8 @@ export function EventConfirmation({
|
|
|
148
148
|
end={event.endDateTime}
|
|
149
149
|
description={event.description}
|
|
150
150
|
location={location}
|
|
151
|
+
icsUrl={event.calendarUrl}
|
|
152
|
+
webcalUrl={event.calendarWebcalUrl}
|
|
151
153
|
style={{ marginTop: 12 }}
|
|
152
154
|
/>
|
|
153
155
|
)}
|
|
@@ -209,7 +211,7 @@ export function EventConfirmation({
|
|
|
209
211
|
)}
|
|
210
212
|
|
|
211
213
|
<dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 10, columnGap: 16, margin: "16px 0 0" }}>
|
|
212
|
-
<ConfirmationRow label="Guest" value={`${order.firstName} ${order.lastName}`.trim() || "
|
|
214
|
+
<ConfirmationRow label="Guest" value={`${order.firstName} ${order.lastName}`.trim() || "Not given"} />
|
|
213
215
|
<ConfirmationRow label="Reference" value={`#${order.id.slice(0, 8).toUpperCase()}`} mono />
|
|
214
216
|
</dl>
|
|
215
217
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Calendar, MapPin } from "lucide-react";
|
|
2
2
|
import type { IEvent } from "../../types/models";
|
|
3
|
-
import {
|
|
3
|
+
import { usePresaleCode } from "../headless/event/usePresaleCode";
|
|
4
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
5
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
6
6
|
import { PriceDisplay } from "../format/PriceDisplay";
|
|
@@ -11,6 +11,7 @@ import { EventTickets } from "./EventTickets";
|
|
|
11
11
|
import { EventCountdown } from "./EventCountdown";
|
|
12
12
|
import { EventWaitlist } from "./EventWaitlist";
|
|
13
13
|
import { AddToCalendar } from "./AddToCalendar";
|
|
14
|
+
import { PresaleCodeField } from "./PresaleCode";
|
|
14
15
|
|
|
15
16
|
export interface EventDetailProps {
|
|
16
17
|
/** Event slug (resolves the event). */
|
|
@@ -59,7 +60,11 @@ export function EventDetail({
|
|
|
59
60
|
const t = useThemeTokens();
|
|
60
61
|
const fmt = useAmountFormatter(formatAmount);
|
|
61
62
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
62
|
-
|
|
63
|
+
// Read the event THROUGH the presale field: it is the same query, fetched
|
|
64
|
+
// with whatever code this buyer has applied, so `tickets` below already
|
|
65
|
+
// contains the tiers that code unlocked.
|
|
66
|
+
const presale = usePresaleCode(slug, { initialEvent });
|
|
67
|
+
const { event, isLoading } = presale;
|
|
63
68
|
|
|
64
69
|
if (isLoading) return <Loading fullPage />;
|
|
65
70
|
if (!event) return <p>Event not found.</p>;
|
|
@@ -116,6 +121,11 @@ export function EventDetail({
|
|
|
116
121
|
end={event.endDateTime}
|
|
117
122
|
description={event.description}
|
|
118
123
|
location={locationText}
|
|
124
|
+
// The platform's own `.ics`, which is what makes an Apple option
|
|
125
|
+
// possible at all. Absent on an older API build, in which case
|
|
126
|
+
// the control quietly falls back to the three template providers.
|
|
127
|
+
icsUrl={event.calendarUrl}
|
|
128
|
+
webcalUrl={event.calendarWebcalUrl}
|
|
119
129
|
style={{ marginTop: 16 }}
|
|
120
130
|
/>
|
|
121
131
|
)}
|
|
@@ -133,7 +143,12 @@ export function EventDetail({
|
|
|
133
143
|
<EventWaitlist event={event} style={{ marginTop: 24 }} />
|
|
134
144
|
</div>
|
|
135
145
|
|
|
136
|
-
{event
|
|
146
|
+
{/* The card also appears for an event with NO visible tickets when a
|
|
147
|
+
presale is the only thing on sale (1.2). Every tier hidden behind a
|
|
148
|
+
code makes `tickets` empty, so gating the card on the tier list alone
|
|
149
|
+
left a presale-only event with no box, no button and nothing to do —
|
|
150
|
+
which is the shape most presales are announced in. */}
|
|
151
|
+
{(event.tickets.length > 0 || presale.visible) && (
|
|
137
152
|
<aside
|
|
138
153
|
className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
|
|
139
154
|
style={{
|
|
@@ -143,27 +158,43 @@ export function EventDetail({
|
|
|
143
158
|
background: t.surface,
|
|
144
159
|
}}
|
|
145
160
|
>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
here, so there is no amount to quote; the RULE is what makes the
|
|
154
|
-
"From" figure honest. Renders nothing when no fee is charged. */}
|
|
155
|
-
{feeNote && (
|
|
156
|
-
<span data-testid="booking-fee-note" style={{ fontSize: 12, opacity: 0.65 }}>
|
|
157
|
-
{feeNote}
|
|
161
|
+
{/* Above the price, because on a presale-only event it is the only
|
|
162
|
+
way to reach one. Renders nothing on an event with no coded
|
|
163
|
+
tier. */}
|
|
164
|
+
<PresaleCodeField presale={presale} />
|
|
165
|
+
{event.tickets.length === 0 && (
|
|
166
|
+
<span style={{ fontSize: 13, opacity: 0.7 }}>
|
|
167
|
+
Tickets for this event are presale only.
|
|
158
168
|
</span>
|
|
159
169
|
)}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
{event.tickets.length > 0 && (
|
|
171
|
+
<>
|
|
172
|
+
<span style={{ fontWeight: 600 }}>
|
|
173
|
+
From{" "}
|
|
174
|
+
<PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
|
|
175
|
+
</span>
|
|
176
|
+
{/* The cheapest tier is the number a buyer anchors on, and until
|
|
177
|
+
now it was the whole story they were given — the artist's
|
|
178
|
+
booking fee was added at checkout and named nowhere. There is
|
|
179
|
+
no selection here, so there is no amount to quote; the RULE is
|
|
180
|
+
what makes the "From" figure honest. Renders nothing when no
|
|
181
|
+
fee is charged, and nothing at all with no price to qualify. */}
|
|
182
|
+
{feeNote && (
|
|
183
|
+
<span data-testid="booking-fee-note" style={{ fontSize: 12, opacity: 0.65 }}>
|
|
184
|
+
{feeNote}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
189
|
+
{event.tickets.length > 0 && (
|
|
190
|
+
<EventTickets
|
|
191
|
+
slug={slug}
|
|
192
|
+
triggerText="Buy tickets"
|
|
193
|
+
formatAmount={fmt}
|
|
194
|
+
finalisePath={finalisePath}
|
|
195
|
+
onComplete={onComplete}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
167
198
|
</aside>
|
|
168
199
|
)}
|
|
169
200
|
</div>
|
|
@@ -128,7 +128,7 @@ export function EventSeriesDetail({
|
|
|
128
128
|
<span>
|
|
129
129
|
{series.dateCount} dates
|
|
130
130
|
{series.firstDateTime && series.lastDateTime
|
|
131
|
-
? ` · ${formatDate(series.firstDateTime, series.events[0]?.timezone)}
|
|
131
|
+
? ` · ${formatDate(series.firstDateTime, series.events[0]?.timezone)} to ${formatDate(
|
|
132
132
|
series.lastDateTime,
|
|
133
133
|
series.events[series.events.length - 1]?.timezone,
|
|
134
134
|
)}`
|