@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
|
@@ -49,8 +49,65 @@ export type MyTicketPass = {
|
|
|
49
49
|
ownerEmail?: string | null;
|
|
50
50
|
/** Set once the pass has been scanned at the door. */
|
|
51
51
|
checkedInAt?: string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Does the CALLER hold this pass right now?
|
|
54
|
+
*
|
|
55
|
+
* Not the same question as "is it on my order", and the difference is the one
|
|
56
|
+
* that trips people up. A purchaser who named guests at checkout owns passes
|
|
57
|
+
* carrying their guests' addresses from the moment they are written, and a
|
|
58
|
+
* transfer moves the holder without touching the order. Every pass-scoped
|
|
59
|
+
* endpoint — transfer, wallet, QR — resolves the HOLDER, so a control drawn
|
|
60
|
+
* on a pass the caller does not hold can only ever 404.
|
|
61
|
+
*
|
|
62
|
+
* Optional because it is newer than the endpoint. `myTicketHeldPassIds`
|
|
63
|
+
* treats its absence as "assume held", which is the old behaviour.
|
|
64
|
+
*/
|
|
65
|
+
isHolder?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The caller's OWN live transfer on this pass, straight from the API.
|
|
68
|
+
*
|
|
69
|
+
* Only ever a transfer THIS reader sent (the server matches `from_email`) and
|
|
70
|
+
* only while it is `pending`, so it never exposes the operator-only chain of
|
|
71
|
+
* previous holders. `null` when nothing is in flight.
|
|
72
|
+
*
|
|
73
|
+
* This is what replaced `localStorage` as the source of truth for "you have a
|
|
74
|
+
* transfer out on this ticket": the id used to exist nowhere but the response
|
|
75
|
+
* to the sender's own send, so the same person on a second device had nothing
|
|
76
|
+
* to withdraw.
|
|
77
|
+
*/
|
|
78
|
+
pendingTransfer?: MyTicketPendingTransfer | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The live claim window, computed server-side on every read. */
|
|
82
|
+
export type MyTicketTransferWindow = {
|
|
83
|
+
/** The claim link still works right now. */
|
|
84
|
+
open: boolean;
|
|
85
|
+
/** Whole seconds left when the server answered. `null` when there is no window. */
|
|
86
|
+
secondsRemaining: number | null;
|
|
87
|
+
/** The instant the link dies. */
|
|
88
|
+
expiresAt: string | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A pending transfer as the SENDER may see it — deliberately narrow.
|
|
93
|
+
*
|
|
94
|
+
* An id to address the withdraw endpoint with, the recipient THIS caller typed
|
|
95
|
+
* themselves, and the two timestamps that make "sent 2 hours ago, expires in
|
|
96
|
+
* 46" renderable. No token, no previous holder, no account ids.
|
|
97
|
+
*/
|
|
98
|
+
export type MyTicketPendingTransfer = {
|
|
99
|
+
id: string;
|
|
100
|
+
status: string;
|
|
101
|
+
toName: string | null;
|
|
102
|
+
toEmail: string;
|
|
103
|
+
expiresAt: string;
|
|
104
|
+
createdAt: string;
|
|
105
|
+
window: MyTicketTransferWindow;
|
|
52
106
|
};
|
|
53
107
|
|
|
108
|
+
/** How the caller comes to be looking at an order. */
|
|
109
|
+
export type MyTicketRole = "purchaser" | "holder";
|
|
110
|
+
|
|
54
111
|
export type MyTicketItem = {
|
|
55
112
|
id: string;
|
|
56
113
|
quantity: number;
|
|
@@ -78,6 +135,27 @@ export type MyTicket = {
|
|
|
78
135
|
eventStatus: string | null;
|
|
79
136
|
items: MyTicketItem[];
|
|
80
137
|
cancellation: TicketCancellation;
|
|
138
|
+
/**
|
|
139
|
+
* `purchaser` — they paid for this order. `holder` — a pass out of it was
|
|
140
|
+
* TRANSFERRED to them and they bought nothing.
|
|
141
|
+
*
|
|
142
|
+
* A holder's row is scrubbed server-side: `totalAmount` and every line price
|
|
143
|
+
* are null, lines they hold no pass on are gone, and `cancellation.canCancel`
|
|
144
|
+
* is false (cancelling refunds the sender's money and voids every seat, which
|
|
145
|
+
* is not a gift recipient's call). Render the money block only for a
|
|
146
|
+
* purchaser — `Number(null)` is `0`, and a confident "$0.00" on somebody
|
|
147
|
+
* else's purchase is worse than saying nothing.
|
|
148
|
+
*
|
|
149
|
+
* Optional: an older API build emits no role, and treating that as
|
|
150
|
+
* `purchaser` reproduces exactly the previous behaviour.
|
|
151
|
+
*/
|
|
152
|
+
role?: MyTicketRole;
|
|
153
|
+
/**
|
|
154
|
+
* The event's `.ics`, both forms — what lets the portal offer Apple Calendar
|
|
155
|
+
* rather than only the three template providers. Null for a cancelled show.
|
|
156
|
+
*/
|
|
157
|
+
calendarUrl?: string | null;
|
|
158
|
+
calendarWebcalUrl?: string | null;
|
|
81
159
|
/**
|
|
82
160
|
* Not emitted by the API, which nests passes under each ITEM. Tolerated by
|
|
83
161
|
* `myTicketPassIds` so an order-level shape would not need a frontend change.
|
|
@@ -111,6 +189,40 @@ export function myTicketPassIds(ticket: Pick<MyTicket, "passes" | "items">): str
|
|
|
111
189
|
return [...seen];
|
|
112
190
|
}
|
|
113
191
|
|
|
192
|
+
/** Every pass on an order, flattened — the objects, not just the ids. */
|
|
193
|
+
export function myTicketPasses(ticket: Pick<MyTicket, "passes" | "items">): MyTicketPass[] {
|
|
194
|
+
const seen = new Map<string, MyTicketPass>();
|
|
195
|
+
const collect = (passes?: MyTicketPass[]) => {
|
|
196
|
+
for (const pass of passes ?? []) {
|
|
197
|
+
if (typeof pass?.id === "string" && /^TN-\d+$/.test(pass.id) && !seen.has(pass.id)) {
|
|
198
|
+
seen.set(pass.id, pass);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
collect(ticket.passes);
|
|
204
|
+
for (const item of ticket.items ?? []) collect(item.passes);
|
|
205
|
+
|
|
206
|
+
return [...seen.values()];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The passes the caller can actually ACT on — transfer, wallet, QR.
|
|
211
|
+
*
|
|
212
|
+
* Every pass-scoped endpoint resolves the current HOLDER, so drawing those
|
|
213
|
+
* controls on a pass the caller merely paid for (a named guest's seat, a ticket
|
|
214
|
+
* already claimed by somebody else) produced a 404 with no explanation. The
|
|
215
|
+
* affordance is now drawn from the server's own `isHolder`.
|
|
216
|
+
*
|
|
217
|
+
* A pass with no `isHolder` at all is treated as held: that is an older API
|
|
218
|
+
* build, and the previous behaviour was to offer the control for every pass.
|
|
219
|
+
*/
|
|
220
|
+
export function myTicketHeldPassIds(ticket: Pick<MyTicket, "passes" | "items">): string[] {
|
|
221
|
+
return myTicketPasses(ticket)
|
|
222
|
+
.filter((pass) => pass.isHolder !== false)
|
|
223
|
+
.map((pass) => pass.id);
|
|
224
|
+
}
|
|
225
|
+
|
|
114
226
|
/**
|
|
115
227
|
* The buyer is resolved from the SESSION server-side — `accountId` here only
|
|
116
228
|
* gates the query on being signed in and keys the cache. It is never sent, and
|
|
@@ -56,6 +56,16 @@ export type CreateOrderResult = {
|
|
|
56
56
|
totalAmount: number;
|
|
57
57
|
subTotal: number;
|
|
58
58
|
shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
|
|
59
|
+
/**
|
|
60
|
+
* When the stock reserved for this cart goes back on sale, ISO-8601.
|
|
61
|
+
*
|
|
62
|
+
* `null` when nothing was reserved — an order that already settled (a free
|
|
63
|
+
* one), a cart of digital/service items, or a profile with inventory holds
|
|
64
|
+
* switched off. `start-payment` restarts this clock and reports the newer
|
|
65
|
+
* instant, so treat that one as authoritative once it arrives; this is what
|
|
66
|
+
* bridges the gap while it is in flight.
|
|
67
|
+
*/
|
|
68
|
+
holdExpiresAt?: string | null;
|
|
59
69
|
};
|
|
60
70
|
|
|
61
71
|
/** Create a cart/product order (the step before start-payment). */
|
|
@@ -37,21 +37,27 @@ import { useForge } from "../../provider/ForgeProvider";
|
|
|
37
37
|
* cancelled transfer are all its answers to give, each with a distinct
|
|
38
38
|
* message.
|
|
39
39
|
*
|
|
40
|
-
* ##
|
|
40
|
+
* ## Where a pending transfer comes from — the SERVER, now
|
|
41
41
|
*
|
|
42
42
|
* `GET /public/events/passes/:passId/transfers` is gated on the operator
|
|
43
|
-
* permission `events.read`
|
|
44
|
-
* current one has no business reading
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* permission `events.read` and stays that way: the chain names every previous
|
|
44
|
+
* holder, which the current one has no business reading. For a while that left
|
|
45
|
+
* a buyer with no endpoint listing their own pending transfer back to them —
|
|
46
|
+
* the id existed nowhere but the response to their own send — so this file
|
|
47
|
+
* remembered it in `localStorage`, exactly as `useEventWaitlist` does for an
|
|
48
|
+
* anonymous joiner's entry id.
|
|
48
49
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* `/public/events/my-tickets` now carries `pendingTransfer` on each pass:
|
|
51
|
+
* `status = 'pending'` AND a `from_email` match on the reader, so the only row
|
|
52
|
+
* that can surface is one the reader sent. **That is the source of truth.** A
|
|
53
|
+
* sender who signed in on a second device can see and withdraw a transfer they
|
|
54
|
+
* started on the first, which is the case `localStorage` could never serve.
|
|
55
|
+
*
|
|
56
|
+
* The local store is KEPT, demoted to what it always really was: a same-render
|
|
57
|
+
* bridge. `my-tickets` is a paginated query that has to round-trip before the
|
|
58
|
+
* new row appears, and a just-sent transfer vanishing for a second reads as a
|
|
59
|
+
* failed send. `mergePendingTransfers` below prefers the server's copy whenever
|
|
60
|
+
* it has one, so the two can never both draw a row.
|
|
55
61
|
*/
|
|
56
62
|
|
|
57
63
|
/** The lifecycle, restated (Forge cannot import from the API). */
|
|
@@ -202,13 +208,67 @@ export const forgetPassTransfer = (transferId: string) => {
|
|
|
202
208
|
* The remembered pending sends, as a REACTIVE read.
|
|
203
209
|
*
|
|
204
210
|
* There is no query to invalidate, so a plain read would leave a just-sent
|
|
205
|
-
* transfer invisible until
|
|
211
|
+
* transfer invisible until `my-tickets` refetches.
|
|
206
212
|
*/
|
|
207
213
|
export function usePendingPassTransfers(passId?: string): RememberedPassTransfer[] {
|
|
208
214
|
const all = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
|
|
209
215
|
return useMemo(() => (passId ? all.filter((entry) => entry.passId === passId) : all), [all, passId]);
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
/** One pending transfer, however the caller came by it. */
|
|
219
|
+
export type PendingTransferView = {
|
|
220
|
+
/** The id the withdraw endpoint takes. */
|
|
221
|
+
transferId: string;
|
|
222
|
+
toEmail: string;
|
|
223
|
+
/** The instant the claim link dies. `null` when unknown (a local-only row). */
|
|
224
|
+
expiresAt: string | null;
|
|
225
|
+
/** False once the window has lapsed — server-computed, never re-derived. */
|
|
226
|
+
open: boolean;
|
|
227
|
+
/** True when this came off `my-tickets` rather than out of `localStorage`. */
|
|
228
|
+
fromServer: boolean;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The pending transfer to DRAW for one pass — the server's if it has one, the
|
|
233
|
+
* local bridge otherwise.
|
|
234
|
+
*
|
|
235
|
+
* De-duplication matters more than it looks: one live transfer per pass is a
|
|
236
|
+
* DATABASE guarantee (a partial unique index), so two rows on screen would be a
|
|
237
|
+
* lie about the ticket's state and would offer two "withdraw" buttons for one
|
|
238
|
+
* thing. The server wins whenever it answers, because it also knows whether the
|
|
239
|
+
* window is still open.
|
|
240
|
+
*
|
|
241
|
+
* `null` means nothing is in flight — a pass with no outstanding transfer, on
|
|
242
|
+
* any device.
|
|
243
|
+
*/
|
|
244
|
+
export function mergePendingTransfer(
|
|
245
|
+
serverTransfer: { id: string; toEmail: string; expiresAt: string; window?: { open: boolean } } | null | undefined,
|
|
246
|
+
remembered: RememberedPassTransfer[],
|
|
247
|
+
): PendingTransferView | null {
|
|
248
|
+
if (serverTransfer) {
|
|
249
|
+
return {
|
|
250
|
+
transferId: serverTransfer.id,
|
|
251
|
+
toEmail: serverTransfer.toEmail,
|
|
252
|
+
expiresAt: serverTransfer.expiresAt ?? null,
|
|
253
|
+
// A lapsed-but-unswept transfer is still `pending` and still withdrawable;
|
|
254
|
+
// the server says `open: false` and the UI says so too rather than hiding
|
|
255
|
+
// a live row the sender cannot otherwise address.
|
|
256
|
+
open: serverTransfer.window?.open ?? true,
|
|
257
|
+
fromServer: true,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const local = remembered[0];
|
|
262
|
+
if (!local) return null;
|
|
263
|
+
return {
|
|
264
|
+
transferId: local.transferId,
|
|
265
|
+
toEmail: local.toEmail,
|
|
266
|
+
expiresAt: local.expiresAt,
|
|
267
|
+
open: true,
|
|
268
|
+
fromServer: false,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
212
272
|
// ── Hooks ───────────────────────────────────────────────────────────────────
|
|
213
273
|
|
|
214
274
|
/**
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import type { PaymentFlowResult, PaymentStartResponse } from "../../types/models";
|
|
3
3
|
import { PaymentProviderName } from "../../types/models";
|
|
4
|
+
import {
|
|
5
|
+
hasPaystackSession,
|
|
6
|
+
openPaystackCheckout,
|
|
7
|
+
type PaystackCheckoutHandlers,
|
|
8
|
+
type PaystackCheckoutOutcome,
|
|
9
|
+
} from "../../utils/paystackCheckout";
|
|
4
10
|
import { useForge } from "../../provider/ForgeProvider";
|
|
5
11
|
import { useMutation } from "@tanstack/react-query";
|
|
6
12
|
|
|
@@ -15,42 +21,80 @@ export interface UsePaymentFlowOptions {
|
|
|
15
21
|
autoStart?: boolean;
|
|
16
22
|
/** Gate auto-start on extra readiness (default true). */
|
|
17
23
|
enabled?: boolean;
|
|
18
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Handle the Paystack checkout automatically once the charge starts: open the
|
|
26
|
+
* inline modal, falling back to the hosted page when it cannot open
|
|
27
|
+
* (default true). `false` means "I will drive it myself": call
|
|
28
|
+
* `openPaystackCheckout()` from a button.
|
|
29
|
+
*
|
|
30
|
+
* The name is historical (it used to mean "redirect to the hosted page") and
|
|
31
|
+
* is deliberately kept: renaming it would break compiling sites for no gain.
|
|
32
|
+
*/
|
|
19
33
|
paystackRedirect?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Paystack modal callbacks. Omit `onPaystackSuccess` to keep the redirect
|
|
36
|
+
* leg's behaviour, which is to navigate to `returnUrl`, and that is what leaves
|
|
37
|
+
* every finalise page unchanged.
|
|
38
|
+
*/
|
|
39
|
+
onPaystackSuccess?: PaystackCheckoutHandlers["onSuccess"];
|
|
40
|
+
/** The fan closed the modal. NOT a failure, so leave them able to retry. */
|
|
41
|
+
onPaystackDismiss?: PaystackCheckoutHandlers["onDismiss"];
|
|
42
|
+
onPaystackError?: PaystackCheckoutHandlers["onError"];
|
|
20
43
|
}
|
|
21
44
|
|
|
22
45
|
/**
|
|
23
46
|
* Starts a payment against any `/public/<domain>/.../start-payment` endpoint and
|
|
24
47
|
* normalizes the result: resolves the provider (the field is named differently
|
|
25
|
-
* per domain) and, for Paystack,
|
|
26
|
-
*
|
|
48
|
+
* per domain) and, for Paystack, opens the inline checkout modal so the fan
|
|
49
|
+
* pays without leaving the creator's site. The Stripe UI (Elements + confirm)
|
|
50
|
+
* stays in the app; this hook only owns the data.
|
|
27
51
|
*/
|
|
28
52
|
export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
29
53
|
const { client, profileId } = useForge();
|
|
30
54
|
const { path, body, returnUrl, autoStart = true, enabled = true, paystackRedirect = true } = opts;
|
|
31
55
|
const startedRef = useRef(false);
|
|
32
56
|
|
|
57
|
+
// Callbacks are read through a ref so a caller passing inline arrows does not
|
|
58
|
+
// re-trigger the auto-open effect on every render.
|
|
59
|
+
const handlersRef = useRef<PaystackCheckoutHandlers>({});
|
|
60
|
+
handlersRef.current = {
|
|
61
|
+
onSuccess: opts.onPaystackSuccess,
|
|
62
|
+
onDismiss: opts.onPaystackDismiss,
|
|
63
|
+
onError: opts.onPaystackError,
|
|
64
|
+
};
|
|
65
|
+
|
|
33
66
|
const mutation = useMutation<PaymentFlowResult, unknown, Record<string, unknown> | undefined>({
|
|
34
67
|
mutationFn: async (override) => {
|
|
35
68
|
// override wins — click-driven flows pass a just-created orderId/returnUrl.
|
|
36
69
|
const res = await client.post(path as string, { ...body, profileId, returnUrl, ...(override ?? {}) });
|
|
37
70
|
const data = res.data as PaymentStartResponse;
|
|
38
71
|
const provider = (data.paymentProvider ?? data.paymentProviderName) as PaymentProviderName | undefined;
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
paystackRedirect &&
|
|
42
|
-
provider === PaymentProviderName.Paystack &&
|
|
43
|
-
data.paymentSecret &&
|
|
44
|
-
typeof window !== "undefined"
|
|
45
|
-
) {
|
|
46
|
-
window.location.href = data.paymentSecret;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
72
|
return { ...data, provider };
|
|
50
73
|
},
|
|
51
74
|
});
|
|
52
75
|
|
|
53
76
|
const { mutate, mutateAsync } = mutation;
|
|
77
|
+
const result = mutation.data ?? null;
|
|
78
|
+
const isPaystack = result?.provider === PaymentProviderName.Paystack;
|
|
79
|
+
|
|
80
|
+
const session = useMemo(
|
|
81
|
+
() => ({ accessCode: result?.accessCode, checkoutUrl: result?.checkoutUrl, returnUrl }),
|
|
82
|
+
[result?.accessCode, result?.checkoutUrl, returnUrl],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Open the Paystack modal for the charge that has already been started.
|
|
87
|
+
* Safe to call again after a dismissal: the access code still opens the same
|
|
88
|
+
* session, which is what makes "closed it by accident" recoverable.
|
|
89
|
+
*/
|
|
90
|
+
const openPaystack = useCallback(async (): Promise<PaystackCheckoutOutcome> => {
|
|
91
|
+
if (!hasPaystackSession(session)) return "unavailable";
|
|
92
|
+
return openPaystackCheckout(session, {
|
|
93
|
+
onSuccess: handlersRef.current.onSuccess,
|
|
94
|
+
onDismiss: handlersRef.current.onDismiss,
|
|
95
|
+
onError: handlersRef.current.onError,
|
|
96
|
+
});
|
|
97
|
+
}, [session]);
|
|
54
98
|
|
|
55
99
|
useEffect(() => {
|
|
56
100
|
if (autoStart && enabled && path && returnUrl && profileId && !startedRef.current) {
|
|
@@ -60,11 +104,32 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
60
104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
105
|
}, [autoStart, enabled, path, returnUrl, profileId]);
|
|
62
106
|
|
|
107
|
+
// Auto-open, once per started charge. Keyed on the charge rather than a bare
|
|
108
|
+
// boolean so a coupon that re-mints the session opens the NEW one, and a fan
|
|
109
|
+
// who dismissed the modal is not immediately re-interrupted by it.
|
|
110
|
+
const autoOpenedRef = useRef<string | null>(null);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!paystackRedirect || !isPaystack) return;
|
|
113
|
+
const key = result?.paymentId || result?.accessCode;
|
|
114
|
+
if (!key || autoOpenedRef.current === key) return;
|
|
115
|
+
autoOpenedRef.current = key;
|
|
116
|
+
void openPaystack();
|
|
117
|
+
}, [paystackRedirect, isPaystack, result?.paymentId, result?.accessCode, openPaystack]);
|
|
118
|
+
|
|
63
119
|
return {
|
|
64
|
-
result
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
120
|
+
result,
|
|
121
|
+
/** STRIPE: the PaymentIntent client secret. Undefined on Paystack. */
|
|
122
|
+
clientSecret: result?.paymentSecret,
|
|
123
|
+
paymentId: result?.paymentId,
|
|
124
|
+
provider: result?.provider,
|
|
125
|
+
/** PAYSTACK: the inline checkout session. Undefined on Stripe. */
|
|
126
|
+
accessCode: result?.accessCode,
|
|
127
|
+
/** PAYSTACK: the hosted page used only when the modal cannot open. */
|
|
128
|
+
checkoutUrl: result?.checkoutUrl,
|
|
129
|
+
isPaystack,
|
|
130
|
+
/** True once there is a Paystack session a pay button can open. */
|
|
131
|
+
canOpenPaystack: hasPaystackSession(session),
|
|
132
|
+
openPaystackCheckout: openPaystack,
|
|
68
133
|
isStarting: mutation.isPending,
|
|
69
134
|
error: mutation.error,
|
|
70
135
|
/**
|
|
@@ -86,6 +151,7 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
86
151
|
*/
|
|
87
152
|
reset: () => {
|
|
88
153
|
startedRef.current = false;
|
|
154
|
+
autoOpenedRef.current = null;
|
|
89
155
|
mutation.reset();
|
|
90
156
|
},
|
|
91
157
|
};
|
|
@@ -82,7 +82,12 @@ export type ApplyCouponResult = {
|
|
|
82
82
|
discountAmount: number;
|
|
83
83
|
chargedAmount: number;
|
|
84
84
|
chargedCurrency: string;
|
|
85
|
+
/** STRIPE: the re-issued PaymentIntent client secret. */
|
|
85
86
|
paymentSecret?: string;
|
|
87
|
+
/** PAYSTACK: the re-issued inline-checkout session. */
|
|
88
|
+
accessCode?: string;
|
|
89
|
+
/** PAYSTACK: the hosted page for the re-issued session (runtime fallback). */
|
|
90
|
+
checkoutUrl?: string;
|
|
86
91
|
paymentId?: string;
|
|
87
92
|
/** Re-quoted sales tax for the re-issued payment intent (display only). */
|
|
88
93
|
taxQuote?: import("../../types/models").PublicTaxQuote;
|
|
@@ -16,11 +16,38 @@ export type CreateSubscriptionInput = {
|
|
|
16
16
|
attributionRefId?: string;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The two things subscribing can produce.
|
|
21
|
+
*
|
|
22
|
+
* A member with no live subscription gets a provider session to pay with:
|
|
23
|
+
* `clientSecret` on Stripe, `accessCode` + `checkoutUrl` on Paystack. A member
|
|
24
|
+
* who ALREADY subscribes to this artist is changing tier: the server moves their
|
|
25
|
+
* existing subscription onto the new price with proration, so there is nothing
|
|
26
|
+
* to pay right now: `requiresPayment` is false, `changed` is true and no
|
|
27
|
+
* session is issued. Branch on `requiresPayment`, never on the presence of a
|
|
28
|
+
* secret alone. A Paystack subscription has never had one, and reading its
|
|
29
|
+
* absence as "nothing to pay" hands out the tier for free.
|
|
30
|
+
*/
|
|
31
|
+
export type CreateSubscriptionResult = {
|
|
32
|
+
membershipId: string;
|
|
33
|
+
subscriptionId: string;
|
|
34
|
+
/** STRIPE ONLY. */
|
|
35
|
+
clientSecret?: string;
|
|
36
|
+
/** PAYSTACK ONLY: inline checkout session. */
|
|
37
|
+
accessCode?: string;
|
|
38
|
+
/** PAYSTACK ONLY: hosted page, the runtime fallback. */
|
|
39
|
+
checkoutUrl?: string;
|
|
40
|
+
currentPeriodStart?: string;
|
|
41
|
+
currentPeriodEnd?: string;
|
|
42
|
+
requiresPayment?: boolean;
|
|
43
|
+
changed?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Subscribe to a paid tier, or move an existing subscription onto another one. */
|
|
20
47
|
export function useCreateSubscription() {
|
|
21
48
|
const { client, profileId } = useForge();
|
|
22
49
|
|
|
23
|
-
return useMutation<
|
|
50
|
+
return useMutation<CreateSubscriptionResult, unknown, CreateSubscriptionInput>({
|
|
24
51
|
mutationFn: async (body) => {
|
|
25
52
|
const res = await client.post("/public/payments/subscriptions", { ...body, profileId });
|
|
26
53
|
return res.data;
|
|
@@ -40,11 +67,30 @@ export function useCreateFreeSubscription() {
|
|
|
40
67
|
});
|
|
41
68
|
}
|
|
42
69
|
|
|
43
|
-
|
|
70
|
+
export type CancelMembershipResult = {
|
|
71
|
+
membershipId: string;
|
|
72
|
+
/** The status AFTER cancelling — still `active` while a paid period runs out. */
|
|
73
|
+
status: string;
|
|
74
|
+
/** True when the cancellation was scheduled rather than applied immediately. */
|
|
75
|
+
cancelAtPeriodEnd: boolean;
|
|
76
|
+
/** ISO date benefits actually stop. Null when they already have. */
|
|
77
|
+
accessUntil: string | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cancel the member's membership.
|
|
82
|
+
*
|
|
83
|
+
* Cancelling a paid, paid-up membership does NOT take effect now: the server
|
|
84
|
+
* schedules it for the end of the period they have already paid for and the
|
|
85
|
+
* member keeps their benefits until `accessUntil`. Free tiers (and any
|
|
86
|
+
* membership with nothing paid up) end immediately. Works for every membership
|
|
87
|
+
* the member can still use — including one whose card is failing, which used to
|
|
88
|
+
* be rejected with an error the UI never showed.
|
|
89
|
+
*/
|
|
44
90
|
export function useCancelMembership() {
|
|
45
91
|
const { client, profileId } = useForge();
|
|
46
92
|
|
|
47
|
-
return useMutation<
|
|
93
|
+
return useMutation<CancelMembershipResult, unknown, { membershipId: string }>({
|
|
48
94
|
mutationFn: async ({ membershipId }) => {
|
|
49
95
|
const res = await client.post("/public/payments/subscriptions/cancel", {
|
|
50
96
|
membershipId,
|
package/src/index.ts
CHANGED
|
@@ -127,6 +127,11 @@ export * from "./utils/landing";
|
|
|
127
127
|
export * from "./utils/metaPixel";
|
|
128
128
|
export * from "./utils/cookieConsent";
|
|
129
129
|
export * from "./utils/structuredData";
|
|
130
|
+
// Paystack inline checkout: the ONE implementation of the popup, exported so a
|
|
131
|
+
// hand-rolled payment surface (or `apps/client`) drives it through the same
|
|
132
|
+
// lazy load, the same runtime fallback and the same return-leg navigation
|
|
133
|
+
// rather than growing a second copy.
|
|
134
|
+
export * from "./utils/paystackCheckout";
|
|
130
135
|
// The per-booking checkout credential + where it is kept across the payment
|
|
131
136
|
// redirect. Exported so a host that drives the booking endpoints itself can
|
|
132
137
|
// hold the secret the same way the built-in flows do.
|
package/src/server/index.ts
CHANGED
|
@@ -47,6 +47,14 @@ import type {
|
|
|
47
47
|
CollectionSearchResult,
|
|
48
48
|
} from "../types/models";
|
|
49
49
|
import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
|
|
50
|
+
// SEO context for structured data. Pure functions over plain data, so this is
|
|
51
|
+
// safe in the React-free server entry.
|
|
52
|
+
import {
|
|
53
|
+
seoContextFromSiteConfig,
|
|
54
|
+
type ReviewSchemaReview,
|
|
55
|
+
type SiteSeoContext,
|
|
56
|
+
} from "../utils/structuredData";
|
|
57
|
+
export type { SiteSeoContext };
|
|
50
58
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
51
59
|
|
|
52
60
|
/**
|
|
@@ -121,6 +129,50 @@ export function fetchSiteConfig(opts: { apiUrl: string; profileId?: string }): P
|
|
|
121
129
|
return getJson<SiteConfig>(opts.apiUrl, "/public/websites/site-config", { profileId: opts.profileId });
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A sample of an entity's published reviews, for the `review` array in its
|
|
134
|
+
* JSON-LD. Sorted by "helpful" so the sample Google reads is the useful one.
|
|
135
|
+
*
|
|
136
|
+
* Purely an enrichment: the star snippet comes from `aggregateRating`, which
|
|
137
|
+
* every detail payload already carries. Only call this when the entity HAS
|
|
138
|
+
* reviews — the sample is worth one round trip for a reviewed thing and worth
|
|
139
|
+
* none for an unreviewed one.
|
|
140
|
+
*/
|
|
141
|
+
export async function fetchEntityReviewsServer(opts: {
|
|
142
|
+
apiUrl: string;
|
|
143
|
+
profileId?: string;
|
|
144
|
+
entityType: "product" | "course" | "coaching_product";
|
|
145
|
+
entityId: string;
|
|
146
|
+
limit?: number;
|
|
147
|
+
}): Promise<ReviewSchemaReview[]> {
|
|
148
|
+
if (!opts.profileId) return [];
|
|
149
|
+
const res = await getJson<{ data: ReviewSchemaReview[] }>(opts.apiUrl, "/public/reviews", {
|
|
150
|
+
profileId: opts.profileId,
|
|
151
|
+
entityType: opts.entityType,
|
|
152
|
+
entityId: opts.entityId,
|
|
153
|
+
page: "1",
|
|
154
|
+
limit: String(opts.limit ?? 5),
|
|
155
|
+
sort: "helpful",
|
|
156
|
+
});
|
|
157
|
+
return res?.data ?? [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The tenant facts the JSON-LD builders need (settlement currency + creator
|
|
162
|
+
* name), for a detail route's OWN loader.
|
|
163
|
+
*
|
|
164
|
+
* Fetched per route rather than read off the root match on purpose: during SSR
|
|
165
|
+
* a child `head()` sees the root match with `loaderData: null`, so the root's
|
|
166
|
+
* site config is invisible there. Pair it with the entity fetch in one
|
|
167
|
+
* `Promise.all` and the extra round trip costs nothing wall-clock.
|
|
168
|
+
*/
|
|
169
|
+
export async function fetchSeoContextServer(opts: {
|
|
170
|
+
apiUrl: string;
|
|
171
|
+
profileId?: string;
|
|
172
|
+
}): Promise<SiteSeoContext> {
|
|
173
|
+
return seoContextFromSiteConfig(await fetchSiteConfig(opts));
|
|
174
|
+
}
|
|
175
|
+
|
|
124
176
|
/** The API's unauthenticated liveness route — no tenant, no params, no body. */
|
|
125
177
|
const PROBE_PATH = "/healthcheck";
|
|
126
178
|
|
package/src/server/platform.ts
CHANGED
|
@@ -376,7 +376,7 @@ export async function handlePlatformEvent(
|
|
|
376
376
|
),
|
|
377
377
|
budget,
|
|
378
378
|
`Handler for "${event.event}" took longer than ${budget}ms. Move the slow part into ` +
|
|
379
|
-
`enqueueAppJob() and return
|
|
379
|
+
`enqueueAppJob() and return. The platform's delivery attempt times out before this finishes.`,
|
|
380
380
|
);
|
|
381
381
|
} catch (err) {
|
|
382
382
|
// 500 so the platform retries. Writes already made carry keys derived from
|