@tribe-nest/forge 3.22.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/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 +16 -0
- package/src/data/queries/useMyBookings.ts +25 -0
- 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/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +52 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +4 -4
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +2 -2
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/useCheckout.ts +72 -8
- 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 +70 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +14 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +22 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +73 -4
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +25 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +3 -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/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +1 -1
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +135 -21
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +2 -2
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/OfferButton.tsx +1 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +1 -1
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +3 -3
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
- 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/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -65,13 +65,13 @@ const WHAT_LABEL: Record<string, string> = {
|
|
|
65
65
|
|
|
66
66
|
/** Human copy for the banner. Pure — no React, no formatting concerns. */
|
|
67
67
|
export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagnosticsMessage {
|
|
68
|
-
const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what}
|
|
68
|
+
const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what}: ${f.error}.`);
|
|
69
69
|
|
|
70
70
|
if (d.probe) {
|
|
71
71
|
details.push(
|
|
72
72
|
d.probe.ok
|
|
73
73
|
? `The API answered ${d.probe.url} in ${d.probe.ms}ms, so this looks like a per-route failure rather than a network one.`
|
|
74
|
-
: `Couldn't reach ${d.probe.url} either
|
|
74
|
+
: `Couldn't reach ${d.probe.url} either: ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
if (d.apiUrl) details.push(`API: ${d.apiUrl}`);
|
|
@@ -84,7 +84,7 @@ export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagno
|
|
|
84
84
|
// The sentence that would have saved the debugging session this was built
|
|
85
85
|
// after: the defaults on screen are a symptom, not the setting.
|
|
86
86
|
consequence:
|
|
87
|
-
"You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived
|
|
87
|
+
"You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived, not because of how your site is configured. Your published site is unaffected.",
|
|
88
88
|
details,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
@@ -227,6 +227,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
227
227
|
const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
|
|
228
228
|
const cancelMembership = useCancelMembership();
|
|
229
229
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
230
|
+
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
230
231
|
|
|
231
232
|
const membership = user?.membership;
|
|
232
233
|
// Two separate questions, deliberately answered separately:
|
|
@@ -238,12 +239,20 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
238
239
|
const openBillingPortal = useOpenBillingPortal();
|
|
239
240
|
const [billingError, setBillingError] = useState<string | null>(null);
|
|
240
241
|
|
|
242
|
+
// A failure here used to be swallowed whole: the button said "Cancelling…",
|
|
243
|
+
// then said "Cancel" again, and the member was never told anything. That is
|
|
244
|
+
// how a free-tier member — whose cancel the server rejected outright — could
|
|
245
|
+
// press this forever with no effect and no explanation.
|
|
241
246
|
const onCancel = async () => {
|
|
242
247
|
if (!membership?.id) return;
|
|
248
|
+
setCancelError(null);
|
|
243
249
|
setIsCancelling(true);
|
|
244
250
|
try {
|
|
245
251
|
await cancelMembership.mutateAsync({ membershipId: membership.id });
|
|
246
252
|
window.location.reload();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
255
|
+
setCancelError(detail || "We couldn't cancel your membership. Please try again or contact us.");
|
|
247
256
|
} finally {
|
|
248
257
|
setIsCancelling(false);
|
|
249
258
|
}
|
|
@@ -302,6 +311,11 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
302
311
|
</p>
|
|
303
312
|
)}
|
|
304
313
|
{billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
|
|
314
|
+
{cancelError && (
|
|
315
|
+
<p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
|
|
316
|
+
{cancelError}
|
|
317
|
+
</p>
|
|
318
|
+
)}
|
|
305
319
|
</div>
|
|
306
320
|
) : (
|
|
307
321
|
<p style={{ opacity: 0.8 }}>You don't have an active membership.</p>
|
|
@@ -315,8 +329,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
315
329
|
)}
|
|
316
330
|
{/* Cancelling stays available for anyone who still HAS access — a
|
|
317
331
|
past_due member must not be trapped in a subscription they can see
|
|
318
|
-
but not leave.
|
|
319
|
-
|
|
332
|
+
but not leave. Hidden once it is already scheduled ("Ending"), where
|
|
333
|
+
the only honest label would be "Cancel again". */}
|
|
334
|
+
{access.hasAccess && !access.cancelAtPeriodEnd && (
|
|
320
335
|
<button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
|
|
321
336
|
{isCancelling ? "Cancelling…" : "Cancel"}
|
|
322
337
|
</button>
|
|
@@ -625,7 +640,7 @@ function WaitlistPlaceRow({
|
|
|
625
640
|
fontSize: 14,
|
|
626
641
|
}}
|
|
627
642
|
>
|
|
628
|
-
<strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}
|
|
643
|
+
<strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}.</strong> Your offer closes{" "}
|
|
629
644
|
{deadline.toLocaleString(undefined, {
|
|
630
645
|
weekday: "short",
|
|
631
646
|
day: "numeric",
|
|
@@ -721,7 +736,60 @@ function formatSessionWindow(booking: MyBooking): string {
|
|
|
721
736
|
});
|
|
722
737
|
const from = start.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
|
|
723
738
|
const to = end.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
|
|
724
|
-
return `${day}, ${from}
|
|
739
|
+
return `${day}, ${from} to ${to}${zone ? ` (${zone})` : ""}`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* WHERE the session is — the line this portal existed without.
|
|
744
|
+
*
|
|
745
|
+
* A client who bought a coaching call could see the time, the price and an
|
|
746
|
+
* add-to-calendar button, and nothing at all about how to attend; the answer
|
|
747
|
+
* arrived, if it arrived, in a separate email from the coach.
|
|
748
|
+
*
|
|
749
|
+
* The two kinds are not interchangeable, so they do not render the same. An
|
|
750
|
+
* online session's link is an anchor — at 9:59 for a 10:00 call it is the only
|
|
751
|
+
* thing on this row anyone wants — shown in full rather than as the word "Join",
|
|
752
|
+
* because the client may be reading this on a phone and about to type it into
|
|
753
|
+
* a laptop. An in-person address is plain text: it is the coach's own words,
|
|
754
|
+
* often "Studio B, ring the buzzer" rather than anything a maps provider could
|
|
755
|
+
* resolve, and linking it would guess at something they did not say.
|
|
756
|
+
*
|
|
757
|
+
* `location` is already resolved server-side (booking override over product
|
|
758
|
+
* default) and is null unless the session is confirmed, so there is no rule to
|
|
759
|
+
* apply here — which is the same discipline `canReschedule` follows two blocks
|
|
760
|
+
* down.
|
|
761
|
+
*/
|
|
762
|
+
function BookingLocationLine({
|
|
763
|
+
location,
|
|
764
|
+
primary,
|
|
765
|
+
}: {
|
|
766
|
+
location: MyBooking["location"];
|
|
767
|
+
primary: string;
|
|
768
|
+
}) {
|
|
769
|
+
if (!location) return null;
|
|
770
|
+
|
|
771
|
+
if (location.type === "online") {
|
|
772
|
+
return (
|
|
773
|
+
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
774
|
+
<span style={{ opacity: 0.7 }}>Join: </span>
|
|
775
|
+
<a
|
|
776
|
+
href={location.url}
|
|
777
|
+
target="_blank"
|
|
778
|
+
rel="noreferrer"
|
|
779
|
+
style={{ color: primary, textDecoration: "underline", wordBreak: "break-all" }}
|
|
780
|
+
>
|
|
781
|
+
{location.url}
|
|
782
|
+
</a>
|
|
783
|
+
</div>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return (
|
|
788
|
+
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
789
|
+
<span style={{ fontWeight: 600 }}>Where: </span>
|
|
790
|
+
{location.address}
|
|
791
|
+
</div>
|
|
792
|
+
);
|
|
725
793
|
}
|
|
726
794
|
|
|
727
795
|
function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
@@ -773,6 +841,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
773
841
|
{booking.coachingProductDurationMinutes != null && (
|
|
774
842
|
<div style={{ fontSize: 13, opacity: 0.7 }}>{booking.coachingProductDurationMinutes} minutes</div>
|
|
775
843
|
)}
|
|
844
|
+
<BookingLocationLine location={booking.location} primary={t.primary} />
|
|
776
845
|
</div>
|
|
777
846
|
<div style={{ textAlign: "right" }}>
|
|
778
847
|
{cancelled && <div style={{ fontSize: 13, color: t.primary }}>Cancelled</div>}
|
|
@@ -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" }}>
|
|
@@ -13,6 +13,7 @@ import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
|
13
13
|
import { Loading } from "./Loading";
|
|
14
14
|
import { CartLineOptions } from "./CartLineOptions";
|
|
15
15
|
import { HoldNotice } from "./HoldNotice";
|
|
16
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
16
17
|
|
|
17
18
|
export interface CheckoutProps {
|
|
18
19
|
/** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
|
|
@@ -798,11 +799,16 @@ export function Checkout({
|
|
|
798
799
|
|
|
799
800
|
// ── Payment section ───────────────────────────────────────────────────────────
|
|
800
801
|
/**
|
|
801
|
-
* The payment stage. The order +
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
* 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.
|
|
806
812
|
*/
|
|
807
813
|
function PaymentSection({
|
|
808
814
|
checkout,
|
|
@@ -812,19 +818,15 @@ function PaymentSection({
|
|
|
812
818
|
onBack?: () => void;
|
|
813
819
|
}) {
|
|
814
820
|
const theme = useForgeTheme();
|
|
815
|
-
const { formatCurrency } = useFormatCurrency();
|
|
816
821
|
const renderPay = usePaymentRenderer();
|
|
817
822
|
const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
|
|
823
|
+
const { canOpenPaystack, openPaystackCheckout } = checkout;
|
|
818
824
|
const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold } = checkout;
|
|
819
825
|
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
|
|
824
|
-
if (isPaystack && paymentSecret && typeof window !== "undefined") {
|
|
825
|
-
window.location.href = paymentSecret;
|
|
826
|
-
}
|
|
827
|
-
}, [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;
|
|
828
830
|
|
|
829
831
|
const backButton = onBack ? (
|
|
830
832
|
<button
|
|
@@ -897,25 +899,16 @@ function PaymentSection({
|
|
|
897
899
|
{/* Suppressed once the server has refused: there is no intent coming, so a
|
|
898
900
|
"Preparing payment…" spinner would sit there forever under a notice
|
|
899
901
|
that has already explained what happened. */}
|
|
900
|
-
{isCreatingOrder && !
|
|
902
|
+
{isCreatingOrder && !hasCharge && !isHoldExpired && <Loading label="Preparing payment…" size={22} />}
|
|
901
903
|
|
|
902
904
|
{isPaystack
|
|
903
|
-
?
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
className="w-full px-4 py-3 font-semibold"
|
|
911
|
-
style={{
|
|
912
|
-
backgroundColor: theme.colors.primary,
|
|
913
|
-
color: readableTextOn(theme.colors.primary),
|
|
914
|
-
borderRadius: `${theme.cornerRadius}px`,
|
|
915
|
-
}}
|
|
916
|
-
>
|
|
917
|
-
{`Pay ${formatCurrency(chargedAmount, chargedCurrency || undefined)} Now`}
|
|
918
|
-
</button>
|
|
905
|
+
? canOpenPaystack && (
|
|
906
|
+
<PaystackPayButton
|
|
907
|
+
onPay={openPaystackCheckout}
|
|
908
|
+
amount={chargedAmount}
|
|
909
|
+
currency={chargedCurrency || undefined}
|
|
910
|
+
disabled={isHoldExpired}
|
|
911
|
+
/>
|
|
919
912
|
)
|
|
920
913
|
: paymentSecret &&
|
|
921
914
|
renderPay && (
|
|
@@ -975,7 +968,7 @@ function ShippingRateSelector({
|
|
|
975
968
|
>
|
|
976
969
|
<div>
|
|
977
970
|
<span className="text-sm font-medium" style={{ color: theme.colors.text }}>
|
|
978
|
-
{group.rates[0].carrier}
|
|
971
|
+
{group.rates[0].carrier} · {group.rates[0].service}
|
|
979
972
|
</span>
|
|
980
973
|
{group.rates[0].estimatedDays != null && (
|
|
981
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 && (
|
|
@@ -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)} />
|
|
@@ -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
|
}
|
|
@@ -211,7 +211,7 @@ export function EventConfirmation({
|
|
|
211
211
|
)}
|
|
212
212
|
|
|
213
213
|
<dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 10, columnGap: 16, margin: "16px 0 0" }}>
|
|
214
|
-
<ConfirmationRow label="Guest" value={`${order.firstName} ${order.lastName}`.trim() || "
|
|
214
|
+
<ConfirmationRow label="Guest" value={`${order.firstName} ${order.lastName}`.trim() || "Not given"} />
|
|
215
215
|
<ConfirmationRow label="Reference" value={`#${order.id.slice(0, 8).toUpperCase()}`} mono />
|
|
216
216
|
</dl>
|
|
217
217
|
</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
|
)}`
|