@tribe-nest/forge 3.22.0 → 3.24.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/useAnalytics.ts +59 -8
- 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/useMusicLink.ts +28 -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 +6 -0
- package/src/server/index.ts +51 -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 +94 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +15 -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 +23 -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/MusicLinkPage.tsx +365 -0
- 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
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { AlertCircle, Clock, KeyRound, X } from "lucide-react";
|
|
2
|
+
import { AlertCircle, CalendarClock, Clock, KeyRound, X } from "lucide-react";
|
|
3
3
|
import type { IEvent, ITicket } from "../../types/models";
|
|
4
4
|
import { useEventCheckout } from "../headless/event/useEventCheckout";
|
|
5
5
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
7
|
import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
8
|
import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
|
|
9
|
+
import {
|
|
10
|
+
maxAddableTickets,
|
|
11
|
+
onSaleBadge,
|
|
12
|
+
onSaleNotice,
|
|
13
|
+
ticketSaleWindow,
|
|
14
|
+
ticketSeatLabel,
|
|
15
|
+
ticketSeatNotice,
|
|
16
|
+
ticketSeatState,
|
|
17
|
+
} from "../format/ticketAvailability";
|
|
9
18
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
10
19
|
import { Loading } from "./Loading";
|
|
20
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
11
21
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
12
22
|
import { buttonStyle } from "./Button";
|
|
13
23
|
import { MembershipGateNotice } from "./MembershipGateNotice";
|
|
@@ -258,9 +268,17 @@ function TicketStep({
|
|
|
258
268
|
};
|
|
259
269
|
|
|
260
270
|
const setQty = (ticket: ITicket, qty: number) => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
// `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
|
|
272
|
+
// on-sale — the same two facts the server sells on. Computed off `now` so
|
|
273
|
+
// the cap opens up on the tick the on-sale time passes, with no reload.
|
|
274
|
+
//
|
|
275
|
+
// 2.6b adds back the seats held for THIS visitor by a waitlist offer.
|
|
276
|
+
// Without it the offeree's own reservation makes the tier read sold out to
|
|
277
|
+
// them and the claim link is unusable.
|
|
278
|
+
c.setTicketQty(
|
|
279
|
+
ticket.id,
|
|
280
|
+
Math.max(0, Math.min(qty, maxAddableTickets(ticket, new Date(now), c.reservedForYou[ticket.id] ?? 0))),
|
|
281
|
+
);
|
|
264
282
|
};
|
|
265
283
|
|
|
266
284
|
return (
|
|
@@ -275,9 +293,27 @@ function TicketStep({
|
|
|
275
293
|
{event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>No tickets available.</p>}
|
|
276
294
|
{event.tickets.map((ticket) => {
|
|
277
295
|
const selectedQty = c.selectedTickets[ticket.id] ?? 0;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
296
|
+
/**
|
|
297
|
+
* `quantity - sold - HELD`, which is what the server sells on. The
|
|
298
|
+
* old `quantity - sold` showed seats sitting in other buyers' live
|
|
299
|
+
* checkouts as available, so a fan in a rush was told "3 left",
|
|
300
|
+
* picked two, filled in the form and was refused at reserve.
|
|
301
|
+
*/
|
|
302
|
+
// 2.6b — the offeree's own reservation is not "somebody else's
|
|
303
|
+
// checkout"; it is the seat they were emailed a deadline for.
|
|
304
|
+
const reserved = c.reservedForYou[ticket.id] ?? 0;
|
|
305
|
+
const seatState = ticketSeatState(ticket, reserved);
|
|
306
|
+
const maxAllowed = maxAddableTickets(ticket, new Date(now), reserved);
|
|
307
|
+
const isSoldOut = seatState !== "available";
|
|
308
|
+
const seatLabel = ticketSeatLabel(seatState);
|
|
309
|
+
const seatNotice = ticketSeatNotice(seatState);
|
|
310
|
+
/**
|
|
311
|
+
* Scheduled on-sale. Enforced since 1.2 and drawn on no storefront,
|
|
312
|
+
* so a tier going on sale Friday was clickable on Wednesday and the
|
|
313
|
+
* `ticket_not_yet_on_sale` refusal arrived on the payment screen.
|
|
314
|
+
*/
|
|
315
|
+
const saleWindow = ticketSaleWindow(ticket, new Date(now));
|
|
316
|
+
const isScheduled = saleWindow.status === "scheduled";
|
|
281
317
|
const u = urgency(ticket.expiresAt);
|
|
282
318
|
const isExpired = !!u?.expired;
|
|
283
319
|
/**
|
|
@@ -332,6 +368,33 @@ function TicketStep({
|
|
|
332
368
|
Presale
|
|
333
369
|
</div>
|
|
334
370
|
)}
|
|
371
|
+
{/* A tier that is announced but not yet buyable says WHEN,
|
|
372
|
+
in the same badge idiom as the presale and expiry ones.
|
|
373
|
+
Deliberately not dimmed and deliberately not hidden: the
|
|
374
|
+
whole point of scheduling an on-sale is that fans can see
|
|
375
|
+
it coming. */}
|
|
376
|
+
{isScheduled && (
|
|
377
|
+
<div
|
|
378
|
+
data-testid="ticket-on-sale-badge"
|
|
379
|
+
style={{
|
|
380
|
+
display: "inline-flex",
|
|
381
|
+
alignItems: "center",
|
|
382
|
+
gap: 4,
|
|
383
|
+
padding: "2px 8px",
|
|
384
|
+
borderRadius: 6,
|
|
385
|
+
fontSize: 12,
|
|
386
|
+
fontWeight: 700,
|
|
387
|
+
marginBottom: 6,
|
|
388
|
+
marginRight: 6,
|
|
389
|
+
background: a(theme.colors.primary, 0.1),
|
|
390
|
+
color: theme.colors.primary,
|
|
391
|
+
border: `1px solid ${a(theme.colors.primary, 0.3)}`,
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
<CalendarClock size={12} />
|
|
395
|
+
{onSaleBadge(saleWindow.startsAt, new Date(now))}
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
335
398
|
{u && !u.expired && (
|
|
336
399
|
<div
|
|
337
400
|
style={{
|
|
@@ -370,22 +433,28 @@ function TicketStep({
|
|
|
370
433
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
371
434
|
<button
|
|
372
435
|
onClick={() => setQty(ticket, selectedQty - 1)}
|
|
373
|
-
disabled
|
|
436
|
+
// Never disabled by availability — only by there being
|
|
437
|
+
// nothing to remove. A buyer holding two seats on a tier
|
|
438
|
+
// that has just gone unavailable must still be able to put
|
|
439
|
+
// them back; disabling this would trap the cart.
|
|
440
|
+
disabled={selectedQty === 0}
|
|
374
441
|
style={stepperStyle(theme, false)}
|
|
375
442
|
>
|
|
376
443
|
−
|
|
377
444
|
</button>
|
|
378
445
|
<span style={{ fontSize: 18, fontWeight: 700, minWidth: 32, textAlign: "center" }}>
|
|
379
|
-
{
|
|
446
|
+
{seatLabel ?? selectedQty}
|
|
380
447
|
</span>
|
|
381
448
|
<button
|
|
382
449
|
onClick={() => setQty(ticket, selectedQty + 1)}
|
|
383
|
-
// A gated tier cannot be added
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
450
|
+
// A gated tier cannot be added, nor one whose on-sale time
|
|
451
|
+
// has not arrived, nor one whose remaining seats are inside
|
|
452
|
+
// somebody else's checkout. Not because the client is the
|
|
453
|
+
// authority — `createOrder` refuses all three regardless —
|
|
454
|
+
// but because letting a buyer build a cart the server will
|
|
455
|
+
// not sell is precisely the "discoverable only by failing"
|
|
387
456
|
// journey this change exists to end.
|
|
388
|
-
disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated}
|
|
457
|
+
disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated || isScheduled}
|
|
389
458
|
style={stepperStyle(theme, true)}
|
|
390
459
|
>
|
|
391
460
|
+
|
|
@@ -396,7 +465,7 @@ function TicketStep({
|
|
|
396
465
|
before that there is nothing to price, and a row of empty
|
|
397
466
|
inputs down the tier list reads as a form to fill in rather
|
|
398
467
|
than a choice to make. */}
|
|
399
|
-
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
|
|
468
|
+
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
|
|
400
469
|
<PwywAmountField
|
|
401
470
|
ticket={ticket}
|
|
402
471
|
quantity={selectedQty}
|
|
@@ -420,6 +489,24 @@ function TicketStep({
|
|
|
420
489
|
loginPath={loginPath}
|
|
421
490
|
membershipPath={membershipPath}
|
|
422
491
|
/>
|
|
492
|
+
{/* The badge says WHEN in two words; this says it in full, with
|
|
493
|
+
the exact time, for a fan deciding whether to set an alarm. */}
|
|
494
|
+
{isScheduled && (
|
|
495
|
+
<div data-testid="ticket-on-sale-notice" style={infoNoticeStyle(theme.colors.primary, theme)}>
|
|
496
|
+
<CalendarClock size={16} />
|
|
497
|
+
{onSaleNotice(saleWindow.startsAt)}
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
{/* "ON HOLD" in the stepper is two words; this is the sentence
|
|
501
|
+
that stops it reading as a dead end. Seats inside somebody
|
|
502
|
+
else's checkout come back if that checkout lapses, which a
|
|
503
|
+
fan told only "SOLD OUT" would never wait for. */}
|
|
504
|
+
{seatNotice && (
|
|
505
|
+
<div data-testid="ticket-on-hold-notice" style={infoNoticeStyle("#f59e0b", theme)}>
|
|
506
|
+
<Clock size={16} />
|
|
507
|
+
{seatNotice}
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
423
510
|
{isExpired && (
|
|
424
511
|
<div
|
|
425
512
|
style={{
|
|
@@ -585,7 +672,7 @@ function DetailsStep({
|
|
|
585
672
|
>
|
|
586
673
|
<span>
|
|
587
674
|
{slot.label}
|
|
588
|
-
{slot.isBuyer && <span style={{ opacity: 0.6 }}>
|
|
675
|
+
{slot.isBuyer && <span style={{ opacity: 0.6 }}> (that’s you)</span>}
|
|
589
676
|
</span>
|
|
590
677
|
<input
|
|
591
678
|
placeholder="Full name"
|
|
@@ -695,7 +782,15 @@ function PaymentStep({
|
|
|
695
782
|
</span>
|
|
696
783
|
</div>
|
|
697
784
|
</div>
|
|
698
|
-
{
|
|
785
|
+
{/* Paystack needs no registered renderer at all (it has its own modal),
|
|
786
|
+
so it is checked BEFORE the "not configured" branch. */}
|
|
787
|
+
{c.isPaystack ? (
|
|
788
|
+
c.canOpenPaystack ? (
|
|
789
|
+
<PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(c.totalAmount)}`} />
|
|
790
|
+
) : (
|
|
791
|
+
<p style={{ opacity: 0.7 }}>Preparing payment…</p>
|
|
792
|
+
)
|
|
793
|
+
) : !renderPayment ? (
|
|
699
794
|
<p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
|
|
700
795
|
) : c.clientSecret ? (
|
|
701
796
|
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
|
|
@@ -905,8 +1000,8 @@ function PwywAmountField({
|
|
|
905
1000
|
Name your price
|
|
906
1001
|
<span style={{ fontWeight: 400, opacity: 0.7 }}>
|
|
907
1002
|
{" "}
|
|
908
|
-
|
|
909
|
-
{maximum != null ? `, up to ${fmt(maximum)}` : ""}
|
|
1003
|
+
(minimum {fmt(floor)}
|
|
1004
|
+
{maximum != null ? `, up to ${fmt(maximum)}` : ""})
|
|
910
1005
|
</span>
|
|
911
1006
|
</label>
|
|
912
1007
|
<input
|
|
@@ -923,9 +1018,9 @@ function PwywAmountField({
|
|
|
923
1018
|
/>
|
|
924
1019
|
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
|
|
925
1020
|
{belowFloor
|
|
926
|
-
? `The minimum is ${fmt(floor)}
|
|
1021
|
+
? `The minimum is ${fmt(floor)}, so we'll use that.`
|
|
927
1022
|
: aboveMax
|
|
928
|
-
? `The most you can pay is ${fmt(maximum!)}
|
|
1023
|
+
? `The most you can pay is ${fmt(maximum!)}, so we'll use that.`
|
|
929
1024
|
: quantity > 1
|
|
930
1025
|
? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
|
|
931
1026
|
: "Per ticket."}
|
|
@@ -934,6 +1029,25 @@ function PwywAmountField({
|
|
|
934
1029
|
);
|
|
935
1030
|
}
|
|
936
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* The inline banner under a tier row — same shape as the expired one already
|
|
1034
|
+
* below it, so "why can't I buy this?" always answers itself in one place and
|
|
1035
|
+
* one visual language, whichever the reason.
|
|
1036
|
+
*/
|
|
1037
|
+
const infoNoticeStyle = (color: string, t: ForgeTheme): CSSProperties => ({
|
|
1038
|
+
marginTop: 12,
|
|
1039
|
+
padding: "8px 12px",
|
|
1040
|
+
borderRadius: t.cornerRadius,
|
|
1041
|
+
fontSize: 14,
|
|
1042
|
+
fontWeight: 600,
|
|
1043
|
+
background: a(color, 0.1),
|
|
1044
|
+
color,
|
|
1045
|
+
border: `1px solid ${a(color, 0.3)}`,
|
|
1046
|
+
display: "flex",
|
|
1047
|
+
alignItems: "center",
|
|
1048
|
+
gap: 8,
|
|
1049
|
+
});
|
|
1050
|
+
|
|
937
1051
|
const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
|
|
938
1052
|
width: 40,
|
|
939
1053
|
height: 40,
|
|
@@ -168,7 +168,9 @@ function WaitlistPlaceCard({
|
|
|
168
168
|
{offerOpen ? (
|
|
169
169
|
<span style={{ display: "flex", alignItems: "center", gap: 6, fontWeight: 700, color: t.primary }}>
|
|
170
170
|
<BellRing size={16} />
|
|
171
|
-
|
|
171
|
+
{/* 2.6b — "reserved for you" is a materially different promise from
|
|
172
|
+
"available", and the fan acts differently on each. */}
|
|
173
|
+
{entry.offer.seatsReserved ? "Reserved for you" : "Tickets available"}
|
|
172
174
|
</span>
|
|
173
175
|
) : entry.position != null ? (
|
|
174
176
|
<span style={{ fontSize: 14, opacity: 0.85 }}>
|
|
@@ -213,6 +215,9 @@ function WaitlistPlaceCard({
|
|
|
213
215
|
*/
|
|
214
216
|
function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
|
|
215
217
|
const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
|
|
218
|
+
// Absent on a payload from an older API build — read as "not reserved", which
|
|
219
|
+
// is the weaker and therefore safe reading.
|
|
220
|
+
const reserved = entry.offer.seatsReserved === true;
|
|
216
221
|
|
|
217
222
|
useEffect(() => {
|
|
218
223
|
setRemaining(entry.offer.secondsRemaining ?? 0);
|
|
@@ -254,8 +259,23 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
|
|
|
254
259
|
</div>
|
|
255
260
|
{deadline && (
|
|
256
261
|
<div style={{ opacity: 0.8, marginTop: 4 }}>
|
|
257
|
-
{
|
|
258
|
-
|
|
262
|
+
{/*
|
|
263
|
+
2.6b — the sentence is chosen by whether the seats are actually
|
|
264
|
+
HELD, not by whether an offer exists.
|
|
265
|
+
|
|
266
|
+
The old copy said both things at once — "your tickets are held
|
|
267
|
+
until Friday" and "tickets are not reserved" — because the second
|
|
268
|
+
was true and the first was aspirational. Now one of them is true per
|
|
269
|
+
row: a reserved offer really does keep the seats off the market for
|
|
270
|
+
the window, and a grandfathered one really does not. Saying the
|
|
271
|
+
strong version of an offer that reserves nothing is the exact
|
|
272
|
+
failure this change closes.
|
|
273
|
+
*/}
|
|
274
|
+
{remaining <= 0
|
|
275
|
+
? `Your offer expired at ${deadline}.`
|
|
276
|
+
: reserved
|
|
277
|
+
? `${entry.quantity === 1 ? "Your ticket is" : `Your ${entry.quantity} tickets are`} reserved until ${deadline}. Nobody else can buy ${entry.quantity === 1 ? "it" : "them"}. Check out from the ticket options above.`
|
|
278
|
+
: `Your offer closes ${deadline}. Tickets are not reserved. Buy from the ticket options above to secure them.`}
|
|
259
279
|
</div>
|
|
260
280
|
)}
|
|
261
281
|
</div>
|
|
@@ -91,7 +91,7 @@ export function HoldNotice({
|
|
|
91
91
|
{/* The server's message names the right noun and the right verb
|
|
92
92
|
tense; ours only stands in before it has spoken. */}
|
|
93
93
|
{expiredMessage ??
|
|
94
|
-
`These ${noun} are no longer held for you. Try again to reserve them
|
|
94
|
+
`These ${noun} are no longer held for you. Try again to reserve them: they may well still be available.`}
|
|
95
95
|
</p>
|
|
96
96
|
{(onRetry || secondaryAction) && (
|
|
97
97
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
|
|
@@ -163,7 +163,7 @@ export function HoldNotice({
|
|
|
163
163
|
</p>
|
|
164
164
|
<p style={{ fontSize: 13, opacity: 0.75, margin: "4px 0 0" }}>
|
|
165
165
|
{hold.isUrgent
|
|
166
|
-
? `Complete payment soon
|
|
166
|
+
? `Complete payment soon: after this your ${noun} go back on sale.`
|
|
167
167
|
: `We're holding your ${noun} while you pay.`}
|
|
168
168
|
</p>
|
|
169
169
|
</div>
|
|
@@ -4,6 +4,7 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
|
4
4
|
import { getCurrencySymbol } from "../format/useFormatCurrency";
|
|
5
5
|
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
6
6
|
import { Loading } from "./Loading";
|
|
7
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
7
8
|
|
|
8
9
|
export interface InvoicePaymentProps {
|
|
9
10
|
invoiceId: string;
|
|
@@ -21,14 +22,14 @@ const formatDate = (value: string | number | Date): string =>
|
|
|
21
22
|
/**
|
|
22
23
|
* Themed invoice view + payment surface, built on `useInvoicePayment` and the
|
|
23
24
|
* app-registered payment renderer (`usePaymentRenderer`). Renders line items,
|
|
24
|
-
* a PDF download, and
|
|
25
|
+
* a PDF download, and (when unpaid) the payment surface: Stripe's card form,
|
|
26
|
+
* or Paystack's inline modal behind a pay button.
|
|
25
27
|
*/
|
|
26
28
|
export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoicePaymentProps) {
|
|
27
29
|
const theme = useForgeTheme();
|
|
28
30
|
const renderPayment = usePaymentRenderer();
|
|
29
|
-
const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote } =
|
|
30
|
-
returnPath
|
|
31
|
-
});
|
|
31
|
+
const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote, isPaystack, canOpenPaystack, openPaystackCheckout } =
|
|
32
|
+
useInvoicePayment(invoiceId, { returnPath });
|
|
32
33
|
|
|
33
34
|
if (invoice.isLoading) return <Loading fullPage />;
|
|
34
35
|
if (invoice.error || !invoice.data) return <p style={{ color: theme.colors.text }}>Invoice not found.</p>;
|
|
@@ -172,7 +173,15 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
|
|
|
172
173
|
<div>
|
|
173
174
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
|
|
174
175
|
{error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
|
|
175
|
-
{
|
|
176
|
+
{/* Paystack brings its own modal, so it is checked before the Stripe
|
|
177
|
+
renderer and needs none of it. */}
|
|
178
|
+
{isPaystack ? (
|
|
179
|
+
canOpenPaystack ? (
|
|
180
|
+
<PaystackPayButton onPay={openPaystackCheckout} label={`Pay ${money(Number(inv.total))}`} />
|
|
181
|
+
) : (
|
|
182
|
+
<Loading label="Preparing payment…" size={22} />
|
|
183
|
+
)
|
|
184
|
+
) : clientSecret ? (
|
|
176
185
|
renderPayment?.({
|
|
177
186
|
clientSecret,
|
|
178
187
|
returnUrl,
|
|
@@ -7,6 +7,7 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
|
7
7
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
8
8
|
import { readableTextOn } from "../theme/contrast";
|
|
9
9
|
import { Loading } from "./Loading";
|
|
10
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
10
11
|
|
|
11
12
|
export interface MembershipCheckoutProps {
|
|
12
13
|
/** Pre-selected tier (e.g. from a `?membershipTierId=` param). */
|
|
@@ -88,23 +89,31 @@ export function MembershipCheckout({
|
|
|
88
89
|
);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
// Payment step
|
|
92
|
-
|
|
92
|
+
// Payment step: the registered payment UI (Stripe) in manual mode, or the
|
|
93
|
+
// Paystack modal behind a pay button. The Paystack branch is also what a
|
|
94
|
+
// member who closed the popup comes back to, so it must render whether or not
|
|
95
|
+
// a Stripe secret was ever issued.
|
|
96
|
+
if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
|
|
93
97
|
return (
|
|
94
98
|
<div className={className} style={card}>
|
|
95
99
|
<h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>Payment</h1>
|
|
96
100
|
<p style={{ opacity: 0.75, marginBottom: 24 }}>
|
|
97
|
-
{tier.name}
|
|
101
|
+
{tier.name} · {fmt(checkout.amount)} / {checkout.billingCycle}
|
|
98
102
|
</p>
|
|
99
103
|
<div style={box}>
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
{checkout.isPaystack ? (
|
|
105
|
+
<PaystackPayButton onPay={checkout.openPaystackCheckout} label={`Pay ${fmt(checkout.amount)}`} />
|
|
106
|
+
) : (
|
|
107
|
+
checkout.clientSecret &&
|
|
108
|
+
renderPayment({
|
|
109
|
+
clientSecret: checkout.clientSecret,
|
|
110
|
+
returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
|
|
111
|
+
amount: checkout.amount,
|
|
112
|
+
mode: "manual",
|
|
113
|
+
onSucceeded: () => onComplete?.(),
|
|
114
|
+
onFailed: () => {},
|
|
115
|
+
})
|
|
116
|
+
)}
|
|
108
117
|
</div>
|
|
109
118
|
<button
|
|
110
119
|
onClick={() => checkout.setStep("select")}
|