@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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { Clock, TimerOff } from "lucide-react";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { readableTextOn } from "../theme/contrast";
|
|
5
|
+
import type { InventoryHoldState } from "../headless/checkout/useInventoryHold";
|
|
6
|
+
|
|
7
|
+
/** Append an alpha to a 6-digit hex colour (mirrors the other styled blocks). */
|
|
8
|
+
const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
|
|
9
|
+
|
|
10
|
+
export interface HoldNoticeProps {
|
|
11
|
+
/** The countdown state from `useInventoryHold`. */
|
|
12
|
+
hold: InventoryHoldState;
|
|
13
|
+
/**
|
|
14
|
+
* The server's own words for a 409 `INVENTORY_HOLD_EXPIRED`. Empty/undefined
|
|
15
|
+
* while nothing has been refused. When set it OUTRANKS the countdown: the
|
|
16
|
+
* server has spoken and the clock on this device is no longer the story.
|
|
17
|
+
*/
|
|
18
|
+
expiredMessage?: string;
|
|
19
|
+
/** Re-take the reservation on the same order. Renders the primary action. */
|
|
20
|
+
onRetry?: () => void;
|
|
21
|
+
/** True while a retry is in flight. */
|
|
22
|
+
isRetrying?: boolean;
|
|
23
|
+
/** Optional escape hatch ("Change selection", "Back to cart"). */
|
|
24
|
+
secondaryAction?: { label: string; onClick: () => void };
|
|
25
|
+
/** What is being held, for the copy. Defaults to "items". */
|
|
26
|
+
noun?: string;
|
|
27
|
+
style?: CSSProperties;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The reservation, told to the buyer.
|
|
32
|
+
*
|
|
33
|
+
* Three states, and they are three different facts:
|
|
34
|
+
*
|
|
35
|
+
* 1. **Running** — "Reserved for you · 8:14 left". Quiet by default, insistent
|
|
36
|
+
* under two minutes. Only drawn when a hold actually exists; a profile with
|
|
37
|
+
* inventory holds switched off, a digital-only cart and an already-settled
|
|
38
|
+
* free order all report no hold and get nothing, which is today's page.
|
|
39
|
+
* 2. **Lapsed on this device** — the clock reached zero. It does not sit on
|
|
40
|
+
* "0:00": it changes what it says, because a timer that finishes and then
|
|
41
|
+
* says nothing has raised an alarm and refused to explain it. It also does
|
|
42
|
+
* not claim the items are gone — this device's clock does not know that.
|
|
43
|
+
* 3. **Refused by the server** (409) — the reservation is definitively over.
|
|
44
|
+
* The message is the API's own, already localised, and it is distinct from
|
|
45
|
+
* sold-out on purpose: the units are very often still there, and the retry
|
|
46
|
+
* below re-acquires exactly the same ones.
|
|
47
|
+
*
|
|
48
|
+
* Every colour comes from the theme. The urgent/expired states use the theme's
|
|
49
|
+
* `secondary` (falling back to `primary`) rather than a hardcoded red, because
|
|
50
|
+
* an artist's site is their brand and a stock crimson is wrong on most of them.
|
|
51
|
+
*/
|
|
52
|
+
export function HoldNotice({
|
|
53
|
+
hold,
|
|
54
|
+
expiredMessage,
|
|
55
|
+
onRetry,
|
|
56
|
+
isRetrying,
|
|
57
|
+
secondaryAction,
|
|
58
|
+
noun = "items",
|
|
59
|
+
style,
|
|
60
|
+
}: HoldNoticeProps): ReactNode {
|
|
61
|
+
const theme = useForgeTheme();
|
|
62
|
+
const accent = theme.colors.secondary ?? theme.colors.primary;
|
|
63
|
+
|
|
64
|
+
const isExpired = !!expiredMessage || hold.hasLapsed;
|
|
65
|
+
if (!isExpired && !hold.isHeld) return null;
|
|
66
|
+
|
|
67
|
+
const shellStyle: CSSProperties = {
|
|
68
|
+
position: "relative",
|
|
69
|
+
display: "flex",
|
|
70
|
+
gap: 12,
|
|
71
|
+
alignItems: "flex-start",
|
|
72
|
+
padding: "12px 14px",
|
|
73
|
+
marginBottom: 16,
|
|
74
|
+
border: `1px solid ${a(isExpired || hold.isUrgent ? accent : theme.colors.primary, isExpired ? 0.5 : 0.28)}`,
|
|
75
|
+
backgroundColor: a(isExpired || hold.isUrgent ? accent : theme.colors.primary, isExpired ? 0.1 : 0.06),
|
|
76
|
+
borderRadius: theme.cornerRadius,
|
|
77
|
+
color: theme.colors.text,
|
|
78
|
+
fontFamily: theme.fontFamily,
|
|
79
|
+
...style,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (isExpired) {
|
|
83
|
+
return (
|
|
84
|
+
<div data-testid="hold-expired" role="alert" style={shellStyle}>
|
|
85
|
+
<TimerOff size={18} style={{ marginTop: 2, flexShrink: 0, color: accent }} aria-hidden />
|
|
86
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
87
|
+
<p style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>
|
|
88
|
+
{expiredMessage ? "Your reservation expired" : "Your reservation has ended"}
|
|
89
|
+
</p>
|
|
90
|
+
<p style={{ fontSize: 13, opacity: 0.8, margin: "4px 0 0" }}>
|
|
91
|
+
{/* The server's message names the right noun and the right verb
|
|
92
|
+
tense; ours only stands in before it has spoken. */}
|
|
93
|
+
{expiredMessage ??
|
|
94
|
+
`These ${noun} are no longer held for you. Try again to reserve them: they may well still be available.`}
|
|
95
|
+
</p>
|
|
96
|
+
{(onRetry || secondaryAction) && (
|
|
97
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
|
|
98
|
+
{onRetry && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
data-testid="hold-retry"
|
|
102
|
+
onClick={onRetry}
|
|
103
|
+
disabled={isRetrying}
|
|
104
|
+
style={{
|
|
105
|
+
padding: "8px 14px",
|
|
106
|
+
fontSize: 14,
|
|
107
|
+
fontWeight: 600,
|
|
108
|
+
cursor: isRetrying ? "default" : "pointer",
|
|
109
|
+
opacity: isRetrying ? 0.6 : 1,
|
|
110
|
+
border: "none",
|
|
111
|
+
backgroundColor: theme.colors.primary,
|
|
112
|
+
color: theme.colors.textPrimary ?? readableTextOn(theme.colors.primary),
|
|
113
|
+
borderRadius: theme.cornerRadius,
|
|
114
|
+
fontFamily: theme.fontFamily,
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
{isRetrying ? "Reserving…" : "Try again"}
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
{secondaryAction && (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
data-testid="hold-secondary"
|
|
124
|
+
onClick={secondaryAction.onClick}
|
|
125
|
+
style={{
|
|
126
|
+
padding: "8px 14px",
|
|
127
|
+
fontSize: 14,
|
|
128
|
+
fontWeight: 600,
|
|
129
|
+
cursor: "pointer",
|
|
130
|
+
background: "transparent",
|
|
131
|
+
border: `1px solid ${theme.colors.primary}`,
|
|
132
|
+
color: theme.colors.primary,
|
|
133
|
+
borderRadius: theme.cornerRadius,
|
|
134
|
+
fontFamily: theme.fontFamily,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{secondaryAction.label}
|
|
138
|
+
</button>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
data-testid="hold-countdown"
|
|
150
|
+
// Polite, not assertive: this updates every second and an assertive live
|
|
151
|
+
// region would have a screen reader interrupt itself all the way down.
|
|
152
|
+
// The number is re-read only when it is worth hearing (see below).
|
|
153
|
+
style={shellStyle}
|
|
154
|
+
>
|
|
155
|
+
<Clock size={18} style={{ marginTop: 2, flexShrink: 0, color: hold.isUrgent ? accent : theme.colors.primary }} aria-hidden />
|
|
156
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
157
|
+
<p style={{ fontSize: 14, fontWeight: 600, margin: 0 }}>
|
|
158
|
+
{`Reserved for you · `}
|
|
159
|
+
<span data-testid="hold-countdown-value" style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
160
|
+
{hold.label}
|
|
161
|
+
</span>
|
|
162
|
+
{` left`}
|
|
163
|
+
</p>
|
|
164
|
+
<p style={{ fontSize: 13, opacity: 0.75, margin: "4px 0 0" }}>
|
|
165
|
+
{hold.isUrgent
|
|
166
|
+
? `Complete payment soon: after this your ${noun} go back on sale.`
|
|
167
|
+
: `We're holding your ${noun} while you pay.`}
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
{/* A screen reader gets a stable sentence rather than a per-second
|
|
171
|
+
barrage: the visible digits tick, this announces the minute. */}
|
|
172
|
+
<span
|
|
173
|
+
aria-live="polite"
|
|
174
|
+
style={{
|
|
175
|
+
position: "absolute",
|
|
176
|
+
width: 1,
|
|
177
|
+
height: 1,
|
|
178
|
+
padding: 0,
|
|
179
|
+
margin: -1,
|
|
180
|
+
overflow: "hidden",
|
|
181
|
+
clip: "rect(0 0 0 0)",
|
|
182
|
+
whiteSpace: "nowrap",
|
|
183
|
+
border: 0,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{hold.secondsRemaining != null && hold.secondsRemaining % 60 === 0
|
|
187
|
+
? `${Math.round(hold.secondsRemaining / 60)} minutes left to complete payment`
|
|
188
|
+
: ""}
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -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")}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { Lock } from "lucide-react";
|
|
3
|
+
import type { PublicMembershipGate } from "../../types/models";
|
|
4
|
+
import type { MembershipGateRefusal } from "../format/membershipGate";
|
|
5
|
+
import { useMembershipGateNotice } from "../headless/membership/useMembershipGateNotice";
|
|
6
|
+
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
7
|
+
import { readableTextOn } from "../theme/contrast";
|
|
8
|
+
|
|
9
|
+
export interface MembershipGateNoticeProps {
|
|
10
|
+
/** The announced gate from the read. */
|
|
11
|
+
gate?: PublicMembershipGate | null;
|
|
12
|
+
/** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
|
|
13
|
+
refusal?: MembershipGateRefusal | null;
|
|
14
|
+
/** What the thing is, in the buyer's words. Default "This". */
|
|
15
|
+
itemLabel?: string;
|
|
16
|
+
/**
|
|
17
|
+
* `badge` is the one-line form for a row in a list of things (a ticket tier);
|
|
18
|
+
* `card` is the block form for a page about ONE thing (a course, a product).
|
|
19
|
+
*/
|
|
20
|
+
variant?: "badge" | "card";
|
|
21
|
+
loginPath?: string;
|
|
22
|
+
membershipPath?: string;
|
|
23
|
+
style?: CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* "Members only" — said BEFORE the buyer pays, with the action that resolves it.
|
|
28
|
+
*
|
|
29
|
+
* ## What was wrong
|
|
30
|
+
*
|
|
31
|
+
* Members-only tickets were enforced at checkout and announced on no surface.
|
|
32
|
+
* A logged-out buyer picked a seat, filled in a two-step form and was refused on
|
|
33
|
+
* the last screen with a 400 — the restriction was discoverable only by failing
|
|
34
|
+
* it, and the "members save X, sign in" funnel the feature was sold on existed
|
|
35
|
+
* nowhere. Products and courses published the same gate and drew it just as
|
|
36
|
+
* little.
|
|
37
|
+
*
|
|
38
|
+
* ## Why the button is the point, not the badge
|
|
39
|
+
*
|
|
40
|
+
* A badge alone converts a silent failure into a visible dead end. The two
|
|
41
|
+
* reasons need genuinely different exits — `sign_in_required` gets a sign-in
|
|
42
|
+
* that RETURNS here, `membership_required` gets the tier — and offering the
|
|
43
|
+
* wrong one is worse than offering none: a signed-in member sent to sign in
|
|
44
|
+
* again learns only that the site is broken. `buildMembershipGateNotice` owns
|
|
45
|
+
* that choice so both rendering stacks make it identically.
|
|
46
|
+
*
|
|
47
|
+
* Renders nothing when nothing is gated, which is also every storefront with the
|
|
48
|
+
* gates switch off.
|
|
49
|
+
*/
|
|
50
|
+
export function MembershipGateNotice({
|
|
51
|
+
gate,
|
|
52
|
+
refusal,
|
|
53
|
+
itemLabel,
|
|
54
|
+
variant = "card",
|
|
55
|
+
loginPath,
|
|
56
|
+
membershipPath,
|
|
57
|
+
style,
|
|
58
|
+
}: MembershipGateNoticeProps) {
|
|
59
|
+
const theme = useForgeTheme();
|
|
60
|
+
const notice = useMembershipGateNotice({ gate, refusal, itemLabel, loginPath, membershipPath });
|
|
61
|
+
if (!notice) return null;
|
|
62
|
+
|
|
63
|
+
if (variant === "badge") {
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
data-testid="membership-gate-badge"
|
|
67
|
+
data-gate-reason={notice.reason}
|
|
68
|
+
style={{
|
|
69
|
+
display: "flex",
|
|
70
|
+
flexWrap: "wrap",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
gap: 8,
|
|
73
|
+
marginTop: 8,
|
|
74
|
+
fontFamily: theme.fontFamily,
|
|
75
|
+
...style,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<span style={pillStyle(theme)}>
|
|
79
|
+
<Lock size={12} aria-hidden />
|
|
80
|
+
{notice.badge}
|
|
81
|
+
</span>
|
|
82
|
+
<span style={{ fontSize: 13, opacity: 0.8, color: theme.colors.text }}>{notice.body}</span>
|
|
83
|
+
{notice.actionHref && (
|
|
84
|
+
<a data-testid="membership-gate-action" href={notice.actionHref} style={linkStyle(theme)}>
|
|
85
|
+
{notice.actionLabel}
|
|
86
|
+
</a>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
data-testid="membership-gate-card"
|
|
95
|
+
data-gate-reason={notice.reason}
|
|
96
|
+
style={{
|
|
97
|
+
border: `1px solid ${alpha(theme.colors.primary, 0.3)}`,
|
|
98
|
+
borderRadius: theme.cornerRadius,
|
|
99
|
+
padding: 20,
|
|
100
|
+
color: theme.colors.text,
|
|
101
|
+
background: theme.colors.background,
|
|
102
|
+
fontFamily: theme.fontFamily,
|
|
103
|
+
...style,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<span style={pillStyle(theme)}>
|
|
107
|
+
<Lock size={12} aria-hidden />
|
|
108
|
+
{notice.badge}
|
|
109
|
+
</span>
|
|
110
|
+
<h3 style={{ fontWeight: 600, fontSize: 17, margin: "12px 0 6px" }}>{notice.title}</h3>
|
|
111
|
+
<p style={{ opacity: 0.8, fontSize: 14, marginBottom: notice.actionHref ? 16 : 0 }}>{notice.body}</p>
|
|
112
|
+
{notice.actionHref && (
|
|
113
|
+
<a
|
|
114
|
+
data-testid="membership-gate-action"
|
|
115
|
+
href={notice.actionHref}
|
|
116
|
+
style={{
|
|
117
|
+
display: "inline-block",
|
|
118
|
+
background: theme.colors.primary,
|
|
119
|
+
color: readableTextOn(theme.colors.primary),
|
|
120
|
+
borderRadius: theme.cornerRadius,
|
|
121
|
+
padding: "10px 18px",
|
|
122
|
+
fontWeight: 600,
|
|
123
|
+
fontSize: 14,
|
|
124
|
+
textDecoration: "none",
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{notice.actionLabel}
|
|
128
|
+
</a>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Append an alpha to a 6-digit hex colour (mirrors frontend-shared addAlphaToHexCode). */
|
|
135
|
+
const alpha = (hex: string, value: number) => hex + Math.round(value * 255).toString(16).padStart(2, "0");
|
|
136
|
+
|
|
137
|
+
// Everything is drawn from the artist's own palette. A members-only badge in a
|
|
138
|
+
// hardcoded gold or indigo is a brand bug on every site that is not that colour.
|
|
139
|
+
const pillStyle = (t: ForgeTheme): CSSProperties => ({
|
|
140
|
+
display: "inline-flex",
|
|
141
|
+
alignItems: "center",
|
|
142
|
+
gap: 4,
|
|
143
|
+
padding: "2px 8px",
|
|
144
|
+
borderRadius: 6,
|
|
145
|
+
fontSize: 12,
|
|
146
|
+
fontWeight: 700,
|
|
147
|
+
background: alpha(t.colors.primary, 0.1),
|
|
148
|
+
color: t.colors.primary,
|
|
149
|
+
border: `1px solid ${alpha(t.colors.primary, 0.3)}`,
|
|
150
|
+
whiteSpace: "nowrap",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const linkStyle = (t: ForgeTheme): CSSProperties => ({
|
|
154
|
+
fontSize: 13,
|
|
155
|
+
fontWeight: 600,
|
|
156
|
+
color: t.colors.primary,
|
|
157
|
+
textDecoration: "underline",
|
|
158
|
+
whiteSpace: "nowrap",
|
|
159
|
+
});
|
|
@@ -5,6 +5,7 @@ import { Loading } from "./Loading";
|
|
|
5
5
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
6
6
|
import { readableTextOn } from "../theme/contrast";
|
|
7
7
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
8
|
+
import { HoldNotice } from "./HoldNotice";
|
|
8
9
|
|
|
9
10
|
export interface OfferButtonProps {
|
|
10
11
|
productId: string;
|
|
@@ -97,6 +98,15 @@ function OfferBody({
|
|
|
97
98
|
<span>Total</span>
|
|
98
99
|
<strong style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</strong>
|
|
99
100
|
</div>
|
|
101
|
+
{/* The reservation, counted down while the buyer pays. Nothing is drawn
|
|
102
|
+
for a digital item or a profile with holds off. */}
|
|
103
|
+
<HoldNotice
|
|
104
|
+
hold={c.hold}
|
|
105
|
+
expiredMessage={c.holdExpiredError ?? undefined}
|
|
106
|
+
onRetry={c.retryHold}
|
|
107
|
+
isRetrying={c.isRetryingHold}
|
|
108
|
+
noun="item"
|
|
109
|
+
/>
|
|
100
110
|
{renderPayment ? (
|
|
101
111
|
<Offer.PaymentStep>{(p) => renderPayment(p)}</Offer.PaymentStep>
|
|
102
112
|
) : (
|
|
@@ -121,7 +131,7 @@ function OfferBody({
|
|
|
121
131
|
>
|
|
122
132
|
{c.variants.map((v) => (
|
|
123
133
|
<option key={v.id} value={v.id}>
|
|
124
|
-
{v.title || [v.color, v.size].filter(Boolean).join(" / ") || "Default"}
|
|
134
|
+
{v.title || [v.color, v.size].filter(Boolean).join(" / ") || "Default"} · {fmt(v.price)}
|
|
125
135
|
</option>
|
|
126
136
|
))}
|
|
127
137
|
</select>
|
|
@@ -157,6 +167,19 @@ function OfferBody({
|
|
|
157
167
|
<span style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</span>
|
|
158
168
|
</div>
|
|
159
169
|
|
|
170
|
+
{/* A reservation that lapsed on the way to the card form never reached
|
|
171
|
+
the payment step, so it is answered here — distinctly from the error
|
|
172
|
+
line, because "your hold ran out" is not "this is gone". */}
|
|
173
|
+
{c.holdExpiredError && (
|
|
174
|
+
<HoldNotice
|
|
175
|
+
hold={c.hold}
|
|
176
|
+
expiredMessage={c.holdExpiredError}
|
|
177
|
+
onRetry={c.retryHold}
|
|
178
|
+
isRetrying={c.isRetryingHold}
|
|
179
|
+
noun="item"
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
182
|
+
|
|
160
183
|
{c.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{c.error}</p>}
|
|
161
184
|
|
|
162
185
|
<button
|
|
@@ -4,6 +4,7 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
|
4
4
|
import { useAmountFormatter } 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 PaymentLinkPaymentProps {
|
|
9
10
|
paymentLinkId: string;
|
|
@@ -22,9 +23,8 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
|
|
|
22
23
|
const theme = useForgeTheme();
|
|
23
24
|
const fmt = useAmountFormatter(formatAmount);
|
|
24
25
|
const renderPayment = usePaymentRenderer();
|
|
25
|
-
const { link, clientSecret, returnUrl, isStarting, error, taxQuote } =
|
|
26
|
-
returnPath
|
|
27
|
-
});
|
|
26
|
+
const { link, clientSecret, returnUrl, isStarting, error, taxQuote, isPaystack, canOpenPaystack, openPaystackCheckout } =
|
|
27
|
+
usePaymentLinkPayment(paymentLinkId, { returnPath });
|
|
28
28
|
|
|
29
29
|
if (link.isLoading) return <Loading fullPage />;
|
|
30
30
|
if (link.error || !link.data) return <p style={{ color: theme.colors.text }}>Payment link not found.</p>;
|
|
@@ -68,7 +68,15 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
|
|
|
68
68
|
<div>
|
|
69
69
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
|
|
70
70
|
{error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
|
|
71
|
-
{
|
|
71
|
+
{/* Paystack brings its own modal, so it is checked before the Stripe
|
|
72
|
+
renderer and needs none of it. */}
|
|
73
|
+
{isPaystack ? (
|
|
74
|
+
canOpenPaystack ? (
|
|
75
|
+
<PaystackPayButton onPay={openPaystackCheckout} label={`Pay ${money(Number(data.amount))}`} />
|
|
76
|
+
) : (
|
|
77
|
+
<Loading label="Preparing payment…" size={22} />
|
|
78
|
+
)
|
|
79
|
+
) : clientSecret ? (
|
|
72
80
|
renderPayment?.({
|
|
73
81
|
clientSecret,
|
|
74
82
|
returnUrl,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { readableTextOn } from "../theme/contrast";
|
|
4
|
+
import { useFormatCurrency } from "../format/useFormatCurrency";
|
|
5
|
+
import type { PaystackCheckoutOutcome } from "../../utils/paystackCheckout";
|
|
6
|
+
|
|
7
|
+
export interface PaystackPayButtonProps {
|
|
8
|
+
/** Opens the modal. Every pillar hook exposes one under this name. */
|
|
9
|
+
onPay: () => Promise<PaystackCheckoutOutcome> | void;
|
|
10
|
+
/** Charged total, MAJOR units. Rendered on the button when set. */
|
|
11
|
+
amount?: number;
|
|
12
|
+
/** ISO currency of `amount`. */
|
|
13
|
+
currency?: string;
|
|
14
|
+
/** Overrides the generated "Pay X" label. */
|
|
15
|
+
label?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Paystack pay button: the fan's way into the inline modal, and their way
|
|
22
|
+
* BACK into it.
|
|
23
|
+
*
|
|
24
|
+
* Two jobs, and the second is the one worth stating: a fan who closes the popup
|
|
25
|
+
* without paying has not failed, and this button is what lets them try again on
|
|
26
|
+
* the same charge. There is no other route back to a session that has already
|
|
27
|
+
* been started, so a surface that renders nothing for Paystack strands them.
|
|
28
|
+
*
|
|
29
|
+
* The button re-enables itself on every outcome except a success, because a
|
|
30
|
+
* success is already navigating away.
|
|
31
|
+
*/
|
|
32
|
+
export function PaystackPayButton({ onPay, amount, currency, label, disabled, className }: PaystackPayButtonProps) {
|
|
33
|
+
const theme = useForgeTheme();
|
|
34
|
+
const { formatCurrency } = useFormatCurrency();
|
|
35
|
+
const [isOpening, setIsOpening] = useState(false);
|
|
36
|
+
|
|
37
|
+
const text = label ?? (amount ? `Pay ${formatCurrency(amount, currency || undefined)}` : "Pay now");
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
disabled={disabled || isOpening}
|
|
43
|
+
onClick={async () => {
|
|
44
|
+
setIsOpening(true);
|
|
45
|
+
try {
|
|
46
|
+
const outcome = await onPay();
|
|
47
|
+
// A success is mid-navigation; anything else means the fan is still
|
|
48
|
+
// here and the button has to work again.
|
|
49
|
+
if (outcome !== "succeeded") setIsOpening(false);
|
|
50
|
+
} catch {
|
|
51
|
+
setIsOpening(false);
|
|
52
|
+
}
|
|
53
|
+
}}
|
|
54
|
+
className={className ?? "w-full px-4 py-3 font-semibold"}
|
|
55
|
+
style={{
|
|
56
|
+
backgroundColor: theme.colors.primary,
|
|
57
|
+
color: readableTextOn(theme.colors.primary),
|
|
58
|
+
borderRadius: `${theme.cornerRadius}px`,
|
|
59
|
+
opacity: disabled || isOpening ? 0.7 : 1,
|
|
60
|
+
cursor: disabled || isOpening ? "not-allowed" : "pointer",
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{isOpening ? "Opening payment…" : text}
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
}
|