@tribe-nest/forge 3.53.0 → 3.54.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 +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useEvents.ts +14 -0
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/i18n/de.json +42 -1
- package/src/i18n/en.json +42 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +92 -0
- package/src/types/models.ts +58 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
- package/src/ui/payment/ForgeStripePayment.tsx +37 -3
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +79 -13
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { IEvent, ITicket } from "../../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ticket requests (curated access, feature 3): the pure decisions behind the
|
|
5
|
+
* picker and the checkout, kept out of the hook so they can be tested without
|
|
6
|
+
* standing up the providers the hook needs.
|
|
7
|
+
*
|
|
8
|
+
* A tier with `requiresApproval` is not sold, it is REQUESTED: the card is
|
|
9
|
+
* committed (authorised) and the seat held, and the organiser has a window to
|
|
10
|
+
* approve or decline. One order is either a request or a purchase, never
|
|
11
|
+
* both: the API refuses a mixed cart (`errors.event.mixed_approval_cart`), and
|
|
12
|
+
* the picker stops the fan building one in the first place.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Does this tier need the organiser's approval? Absent on an older API build means no. */
|
|
16
|
+
export function tierRequiresApproval(ticket: Pick<ITicket, "requiresApproval"> | undefined): boolean {
|
|
17
|
+
return ticket?.requiresApproval === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Does this event carry any approval-required tier at all? Read off the tiers, not the flag, so a partial payload still answers. */
|
|
21
|
+
export function eventHasApprovalTiers(event: Pick<IEvent, "tickets" | "hasApprovalTiers"> | undefined): boolean {
|
|
22
|
+
if (!event) return false;
|
|
23
|
+
if (event.hasApprovalTiers === true) return true;
|
|
24
|
+
return (event.tickets ?? []).some(tierRequiresApproval);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which kind of order the current selection would make. `null` while nothing
|
|
29
|
+
* is selected (either kind is still addable), `"request"` once an approval
|
|
30
|
+
* tier is in, `"purchase"` once an ordinary one is. A mixed selection cannot
|
|
31
|
+
* arise through `tierBlockedByApprovalMix`, but if one is handed in (a stale
|
|
32
|
+
* cache, a tier that changed mode underneath the fan) it reads as a request,
|
|
33
|
+
* because that is the button the server will refuse rather than the one it
|
|
34
|
+
* will silently sell.
|
|
35
|
+
*/
|
|
36
|
+
export function selectionMode(
|
|
37
|
+
tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
|
|
38
|
+
quantities: Record<string, number>,
|
|
39
|
+
): "request" | "purchase" | null {
|
|
40
|
+
let approval = false;
|
|
41
|
+
let ordinary = false;
|
|
42
|
+
for (const ticket of tickets) {
|
|
43
|
+
if ((quantities[ticket.id] ?? 0) <= 0) continue;
|
|
44
|
+
if (tierRequiresApproval(ticket)) approval = true;
|
|
45
|
+
else ordinary = true;
|
|
46
|
+
}
|
|
47
|
+
if (approval) return "request";
|
|
48
|
+
if (ordinary) return "purchase";
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The selection contains at least one approval tier, so submitting it lodges a request. */
|
|
53
|
+
export function selectionRequiresApproval(
|
|
54
|
+
tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
|
|
55
|
+
quantities: Record<string, number>,
|
|
56
|
+
): boolean {
|
|
57
|
+
return selectionMode(tickets, quantities) === "request";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* May this tier be added to the current selection?
|
|
62
|
+
*
|
|
63
|
+
* Blocked when the selection already holds the OTHER kind: an approval tier
|
|
64
|
+
* beside a purchase, or a purchase beside a request. A tier that is already in
|
|
65
|
+
* the selection is never blocked (it is the kind that set the mode), and an
|
|
66
|
+
* empty selection blocks nothing.
|
|
67
|
+
*/
|
|
68
|
+
export function tierBlockedByApprovalMix(
|
|
69
|
+
ticket: Pick<ITicket, "id" | "requiresApproval">,
|
|
70
|
+
tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
|
|
71
|
+
quantities: Record<string, number>,
|
|
72
|
+
): boolean {
|
|
73
|
+
if ((quantities[ticket.id] ?? 0) > 0) return false;
|
|
74
|
+
const mode = selectionMode(tickets, quantities);
|
|
75
|
+
if (mode === null) return false;
|
|
76
|
+
return tierRequiresApproval(ticket) ? mode === "purchase" : mode === "request";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The fields of a `POST /public/events/:id/orders` response the routing reads. */
|
|
80
|
+
export interface TicketOrderRoutingInput {
|
|
81
|
+
isFreeCheckout?: boolean;
|
|
82
|
+
awaitingApproval?: boolean;
|
|
83
|
+
totalAmount?: number | string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Where the checkout goes once the order row exists.
|
|
88
|
+
*
|
|
89
|
+
* complete nothing to pay: a free purchase (passes minted, `isFreeCheckout`)
|
|
90
|
+
* or a FREE REQUEST (already `pending_approval`, `isFreeCheckout`
|
|
91
|
+
* false, `awaitingApproval` true, total 0). Both hand the order id
|
|
92
|
+
* to `onComplete`, and the host navigates to the finalise page,
|
|
93
|
+
* which renders the right outcome off the order's own status.
|
|
94
|
+
* payment there is money on it: start the charge. A priced request runs
|
|
95
|
+
* the payment step exactly as a sale does; only the wording and
|
|
96
|
+
* the settlement differ, and start-payment says which.
|
|
97
|
+
*
|
|
98
|
+
* `isFreeCheckout` wins outright, as it always has. The free-request branch is
|
|
99
|
+
* keyed on the TOTAL, not on `awaitingApproval` alone, because a priced
|
|
100
|
+
* request also carries `awaitingApproval: true` and must still reach the card.
|
|
101
|
+
*/
|
|
102
|
+
export function nextStepAfterTicketOrder(order: TicketOrderRoutingInput): "complete" | "payment" {
|
|
103
|
+
if (order.isFreeCheckout) return "complete";
|
|
104
|
+
if (order.awaitingApproval === true) {
|
|
105
|
+
const total = Number(order.totalAmount ?? 0);
|
|
106
|
+
if (!Number.isFinite(total) || total <= 0) return "complete";
|
|
107
|
+
}
|
|
108
|
+
return "payment";
|
|
109
|
+
}
|
|
@@ -22,6 +22,12 @@ import { checkoutAmounts, isPayWhatYouWant, resolveUnitPrice, ticketSubtotals }
|
|
|
22
22
|
import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
|
|
23
23
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
24
24
|
import { readLanding } from "../../../utils/landing";
|
|
25
|
+
import {
|
|
26
|
+
eventHasApprovalTiers,
|
|
27
|
+
nextStepAfterTicketOrder,
|
|
28
|
+
selectionRequiresApproval,
|
|
29
|
+
tierBlockedByApprovalMix,
|
|
30
|
+
} from "./ticketApproval";
|
|
25
31
|
|
|
26
32
|
export type EventCheckoutStep = "tickets" | "details" | "payment";
|
|
27
33
|
|
|
@@ -32,7 +38,10 @@ export interface UseEventCheckoutOptions {
|
|
|
32
38
|
finalisePath?: (slug: string, orderId: string) => string;
|
|
33
39
|
/** Called when a FREE order completes in-app — the host navigates however it
|
|
34
40
|
* wants (e.g. its router), instead of Forge doing a `window.location` redirect.
|
|
35
|
-
* Omit to keep the default redirect.
|
|
41
|
+
* Omit to keep the default redirect. Also fired for a FREE TICKET REQUEST
|
|
42
|
+
* (`awaitingApproval` with nothing to pay), which has no payment step either:
|
|
43
|
+
* the finalise page it lands on reads the order's status and renders
|
|
44
|
+
* "request received". */
|
|
36
45
|
onComplete?: (info: { slug: string; orderId: string }) => void;
|
|
37
46
|
}
|
|
38
47
|
|
|
@@ -179,6 +188,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
179
188
|
[selectedTickets],
|
|
180
189
|
);
|
|
181
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Ticket requests (curated access, feature 3).
|
|
193
|
+
*
|
|
194
|
+
* `hasApprovalTiers` says the event carries one at all; `awaitingApproval`
|
|
195
|
+
* says THIS selection is a request rather than a purchase, which is what the
|
|
196
|
+
* primary button ("Request tickets") and the payment wording read. One order
|
|
197
|
+
* is either, never both: `isTierBlockedByApprovalMix` is what the picker
|
|
198
|
+
* disables the `+` on, so the server's mixed-cart refusal is never reached.
|
|
199
|
+
*/
|
|
200
|
+
const hasApprovalTiers = eventHasApprovalTiers(event);
|
|
201
|
+
const awaitingApproval = useMemo(
|
|
202
|
+
() => selectionRequiresApproval(event?.tickets ?? [], selectedTickets),
|
|
203
|
+
[event, selectedTickets],
|
|
204
|
+
);
|
|
205
|
+
const isTierBlockedByApprovalMix = (ticketId: string) => {
|
|
206
|
+
const ticket = event?.tickets.find((t) => t.id === ticketId);
|
|
207
|
+
return ticket ? tierBlockedByApprovalMix(ticket, event?.tickets ?? [], selectedTickets) : false;
|
|
208
|
+
};
|
|
209
|
+
/** When the request lapses if the organiser does not decide, from the order response. */
|
|
210
|
+
const [requestExpiresAt, setRequestExpiresAt] = useState<string | null>(null);
|
|
211
|
+
|
|
182
212
|
/**
|
|
183
213
|
* The artist's booking fee (1.4) — the thing the buyer was charged and never
|
|
184
214
|
* told about, on any surface, on either rendering stack.
|
|
@@ -212,6 +242,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
212
242
|
// rejects those with "quantity must be positive").
|
|
213
243
|
const setTicketQty = (ticketId: string, qty: number) =>
|
|
214
244
|
setSelectedTickets((prev) => {
|
|
245
|
+
// A request and a purchase cannot share an order. The picker disables
|
|
246
|
+
// the control, and this refuses the write too, so a host that drives the
|
|
247
|
+
// hook from its own UI cannot build the cart the server will refuse.
|
|
248
|
+
if (qty > 0 && (prev[ticketId] ?? 0) === 0 && event) {
|
|
249
|
+
const ticket = event.tickets.find((t) => t.id === ticketId);
|
|
250
|
+
if (ticket && tierBlockedByApprovalMix(ticket, event.tickets, prev)) return prev;
|
|
251
|
+
}
|
|
215
252
|
const next = { ...prev };
|
|
216
253
|
if (qty > 0) next[ticketId] = qty;
|
|
217
254
|
else delete next[ticketId];
|
|
@@ -253,6 +290,14 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
253
290
|
return false;
|
|
254
291
|
}
|
|
255
292
|
if (!event) return false;
|
|
293
|
+
// A request is not a purchase and cannot ride in a bundle: the bundle
|
|
294
|
+
// checkout forces payment and the API refuses approval tiers in one. Sent
|
|
295
|
+
// to the cart it would fail at the end of a longer journey, so it is
|
|
296
|
+
// refused here, and the surface hides the exit for a request anyway.
|
|
297
|
+
if (awaitingApproval) {
|
|
298
|
+
setError("Tickets that need our approval are requested on their own.");
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
256
301
|
setError(null);
|
|
257
302
|
setTickets({
|
|
258
303
|
eventId: event.id,
|
|
@@ -372,12 +417,15 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
372
417
|
// legitimately reports 0 — falling back to the estimate there would put a
|
|
373
418
|
// fee on a comp.
|
|
374
419
|
setServerFeeAmount(data.feeAmount ?? 0);
|
|
420
|
+
setRequestExpiresAt(data.requestExpiresAt ?? null);
|
|
375
421
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
376
422
|
const ru = `${origin}${finalisePath(slug, data.orderId)}`;
|
|
377
423
|
setReturnUrl(ru);
|
|
378
|
-
if (data
|
|
379
|
-
//
|
|
380
|
-
//
|
|
424
|
+
if (nextStepAfterTicketOrder(data) === "complete") {
|
|
425
|
+
// Nothing to pay: a free order, or a free ticket REQUEST that is already
|
|
426
|
+
// `pending_approval`. Hand navigation to the host if it asked, else
|
|
427
|
+
// default to an in-app redirect to the finalise page, which renders the
|
|
428
|
+
// right outcome off the order's own status.
|
|
381
429
|
if (opts.onComplete) opts.onComplete({ slug, orderId: data.orderId });
|
|
382
430
|
else if (typeof window !== "undefined") window.location.href = ru;
|
|
383
431
|
return;
|
|
@@ -436,6 +484,7 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
436
484
|
// subtotal, so keeping it would understate the fee on the fresh, full-price
|
|
437
485
|
// order the next `continueToPayment` creates.
|
|
438
486
|
setServerFeeAmount(null);
|
|
487
|
+
setRequestExpiresAt(null);
|
|
439
488
|
setStep("details");
|
|
440
489
|
};
|
|
441
490
|
|
|
@@ -493,6 +542,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
493
542
|
/** What each selected PWYW tier will actually be charged, per unit. */
|
|
494
543
|
effectiveAmounts,
|
|
495
544
|
ticketCount,
|
|
545
|
+
/**
|
|
546
|
+
* Ticket requests (curated access, feature 3). `hasApprovalTiers`: the
|
|
547
|
+
* event carries at least one tier the organiser approves. `awaitingApproval`:
|
|
548
|
+
* THIS selection is a request, so the primary button reads "Request
|
|
549
|
+
* tickets", the pay button "Authorize", and nothing is sold until the
|
|
550
|
+
* organiser decides. `isTierBlockedByApprovalMix(id)`: the tier cannot join
|
|
551
|
+
* the current selection because one order is either a request or a
|
|
552
|
+
* purchase, never both. `requestExpiresAt`: when an undecided request
|
|
553
|
+
* lapses, once the order exists.
|
|
554
|
+
*/
|
|
555
|
+
hasApprovalTiers,
|
|
556
|
+
awaitingApproval,
|
|
557
|
+
isTierBlockedByApprovalMix,
|
|
558
|
+
requestExpiresAt,
|
|
559
|
+
/**
|
|
560
|
+
* From start-payment: `"deferred"` on a priced request, so the payment
|
|
561
|
+
* renderer can label the button "Authorize" and say the card is charged
|
|
562
|
+
* only on approval. Undefined on a sale.
|
|
563
|
+
*/
|
|
564
|
+
settlement: flow.settlement,
|
|
565
|
+
deferredStrategy: flow.deferredStrategy,
|
|
496
566
|
// The server's figure wins the moment there is one: start-payment first,
|
|
497
567
|
// then the order response (which is already net of any discount), and only
|
|
498
568
|
// the locally summed ticket prices before either exists.
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -53,6 +53,17 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
|
53
53
|
// Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
|
|
54
54
|
// each composed from the data hooks so a creator can rebuild any page.
|
|
55
55
|
export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
|
|
56
|
+
// Ticket requests (curated access): the pure decisions behind the picker and
|
|
57
|
+
// the post-order routing, for a host that drives the hook from its own UI.
|
|
58
|
+
export {
|
|
59
|
+
eventHasApprovalTiers,
|
|
60
|
+
nextStepAfterTicketOrder,
|
|
61
|
+
selectionMode,
|
|
62
|
+
selectionRequiresApproval,
|
|
63
|
+
tierBlockedByApprovalMix,
|
|
64
|
+
tierRequiresApproval,
|
|
65
|
+
type TicketOrderRoutingInput,
|
|
66
|
+
} from "./event/ticketApproval";
|
|
56
67
|
export {
|
|
57
68
|
usePresaleCode,
|
|
58
69
|
type PresaleCodeField,
|
package/src/ui/index.ts
CHANGED
|
@@ -73,6 +73,15 @@ export {
|
|
|
73
73
|
export { PresaleCodeField, type PresaleCodeFieldProps } from "./styled/PresaleCode";
|
|
74
74
|
export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
|
|
75
75
|
export { ForgeStripePayment } from "./payment/ForgeStripePayment";
|
|
76
|
+
// For a custom payment renderer: which post-confirm statuses count as done
|
|
77
|
+
// (a deferred charge stops at `requires_capture`), and the deferred-charge
|
|
78
|
+
// wording decision the built-in renderer makes.
|
|
79
|
+
export {
|
|
80
|
+
deferredChargeNotice,
|
|
81
|
+
isDeferredSettlement,
|
|
82
|
+
isManualConfirmSuccess,
|
|
83
|
+
MANUAL_CONFIRM_SUCCESS_STATUSES,
|
|
84
|
+
} from "./payment/stripeConfirmOutcome";
|
|
76
85
|
export { Cart, type CartProps } from "./styled/Cart";
|
|
77
86
|
/** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
|
|
78
87
|
export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
|
|
@@ -346,3 +355,12 @@ export * from "./styled/work";
|
|
|
346
355
|
|
|
347
356
|
// Unified member home (`/i/members`) styled blocks.
|
|
348
357
|
export * from "./styled/members";
|
|
358
|
+
|
|
359
|
+
// The review page a "how did we do?" email lands on. Here rather than in a
|
|
360
|
+
// storefront because both the hosted `/i/reviews/$token` page and every site's
|
|
361
|
+
// root-level `/reviews/$token` alias render it.
|
|
362
|
+
export * from "./styled/ReviewRequestPage";
|
|
363
|
+
// The contract signing page. Here for the same reason: both storefronts render
|
|
364
|
+
// it, at BOTH the canonical `/i/documents/$token` and the older
|
|
365
|
+
// `/documents/$token` that already-sent links point at.
|
|
366
|
+
export * from "./styled/DocumentSigningPage";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DeferredChargeStrategy, PaymentSettlement } from "../../types/models";
|
|
1
2
|
import { createContext, useContext, type ReactNode } from "react";
|
|
2
3
|
import { ForgeStripePayment } from "./ForgeStripePayment";
|
|
3
4
|
|
|
@@ -16,6 +17,20 @@ export interface PaymentRenderProps {
|
|
|
16
17
|
mode?: "redirect" | "manual";
|
|
17
18
|
onSucceeded?: () => void;
|
|
18
19
|
onFailed?: (message?: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* `"deferred"` when the charge is committed now and taken only if the
|
|
22
|
+
* organiser approves (a ticket request). The built-in renderer then labels
|
|
23
|
+
* the button "Authorize {amount}" and says under it that the card is charged
|
|
24
|
+
* only on approval. Absent on every ordinary sale, so a custom renderer that
|
|
25
|
+
* ignores it keeps working exactly as before.
|
|
26
|
+
*/
|
|
27
|
+
settlement?: PaymentSettlement;
|
|
28
|
+
/**
|
|
29
|
+
* How the provider defers: `"authorization"` shows a temporary hold on the
|
|
30
|
+
* fan's statement and the renderer says so; `"saved_instrument"` takes
|
|
31
|
+
* nothing until approval. Only sent with `settlement: "deferred"`.
|
|
32
|
+
*/
|
|
33
|
+
deferredStrategy?: DeferredChargeStrategy | null;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
export type PaymentRenderer = (props: PaymentRenderProps) => ReactNode;
|
|
@@ -5,7 +5,9 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
|
5
5
|
import { readableTextOn } from "../theme/contrast";
|
|
6
6
|
import { useFormatCurrency } from "../format/useFormatCurrency";
|
|
7
7
|
import { useSiteConfig } from "../../data/queries/useWebsite";
|
|
8
|
+
import { useForgeT } from "../../i18n";
|
|
8
9
|
import type { PaymentRenderProps } from "./ForgePaymentProvider";
|
|
10
|
+
import { deferredChargeNotice, isDeferredSettlement, isManualConfirmSuccess } from "./stripeConfirmOutcome";
|
|
9
11
|
|
|
10
12
|
function alpha(hex: number) {
|
|
11
13
|
return Math.round(hex * 255)
|
|
@@ -30,6 +32,8 @@ export function ForgeStripePayment({
|
|
|
30
32
|
mode = "redirect",
|
|
31
33
|
onSucceeded,
|
|
32
34
|
onFailed,
|
|
35
|
+
settlement,
|
|
36
|
+
deferredStrategy,
|
|
33
37
|
}: PaymentRenderProps) {
|
|
34
38
|
const theme = useForgeTheme();
|
|
35
39
|
const { data: siteConfig } = useSiteConfig();
|
|
@@ -91,7 +95,16 @@ export function ForgeStripePayment({
|
|
|
91
95
|
|
|
92
96
|
return (
|
|
93
97
|
<Elements options={{ clientSecret, appearance, loader: "auto" }} stripe={stripePromise}>
|
|
94
|
-
<Inner
|
|
98
|
+
<Inner
|
|
99
|
+
returnUrl={returnUrl}
|
|
100
|
+
amount={amount}
|
|
101
|
+
currency={currency}
|
|
102
|
+
mode={mode}
|
|
103
|
+
onSucceeded={onSucceeded}
|
|
104
|
+
onFailed={onFailed}
|
|
105
|
+
settlement={settlement}
|
|
106
|
+
deferredStrategy={deferredStrategy}
|
|
107
|
+
/>
|
|
95
108
|
</Elements>
|
|
96
109
|
);
|
|
97
110
|
}
|
|
@@ -103,15 +116,26 @@ function Inner({
|
|
|
103
116
|
mode = "redirect",
|
|
104
117
|
onSucceeded,
|
|
105
118
|
onFailed,
|
|
119
|
+
settlement,
|
|
120
|
+
deferredStrategy,
|
|
106
121
|
}: Omit<PaymentRenderProps, "clientSecret">) {
|
|
107
122
|
const theme = useForgeTheme();
|
|
123
|
+
const t = useForgeT();
|
|
108
124
|
const { formatCurrency } = useFormatCurrency();
|
|
109
125
|
const stripe = useStripe();
|
|
110
126
|
const elements = useElements();
|
|
111
127
|
const [message, setMessage] = useState("");
|
|
112
128
|
const [isLoading, setIsLoading] = useState(false);
|
|
113
129
|
|
|
114
|
-
|
|
130
|
+
// A deferred charge is not a payment yet: the button says what actually
|
|
131
|
+
// happens when it is pressed, and the line under it says when the money
|
|
132
|
+
// moves. That line is content, not help: a fan reading "Authorize" without
|
|
133
|
+
// it has been told their card was committed and nothing about why.
|
|
134
|
+
const deferred = isDeferredSettlement(settlement);
|
|
135
|
+
const formattedAmount = formatCurrency(amount, currency || undefined);
|
|
136
|
+
const buttonText = deferred
|
|
137
|
+
? t("forge.forge_stripe_payment.authorize", { amount: formattedAmount })
|
|
138
|
+
: `Pay ${formattedAmount} Now`;
|
|
115
139
|
|
|
116
140
|
const handleSubmit = useCallback(async () => {
|
|
117
141
|
if (!stripe || !elements) return;
|
|
@@ -131,7 +155,9 @@ function Inner({
|
|
|
131
155
|
onFailed?.(msg);
|
|
132
156
|
return;
|
|
133
157
|
}
|
|
134
|
-
|
|
158
|
+
// `requires_capture` is a deferred charge that went through: the card is
|
|
159
|
+
// authorised and the platform captures it on approval. See the helper.
|
|
160
|
+
if (isManualConfirmSuccess(paymentIntent?.status)) {
|
|
135
161
|
setIsLoading(false);
|
|
136
162
|
onSucceeded?.();
|
|
137
163
|
return;
|
|
@@ -166,6 +192,14 @@ function Inner({
|
|
|
166
192
|
>
|
|
167
193
|
{isLoading ? "Processing…" : buttonText}
|
|
168
194
|
</button>
|
|
195
|
+
{deferred && (
|
|
196
|
+
<p data-testid="deferred-charge-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
|
|
197
|
+
{t("forge.forge_stripe_payment.deferred_charged_on_approval")}
|
|
198
|
+
{deferredChargeNotice(deferredStrategy) === "authorization" && (
|
|
199
|
+
<> {t("forge.forge_stripe_payment.deferred_hold_on_statement")}</>
|
|
200
|
+
)}
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
169
203
|
</form>
|
|
170
204
|
);
|
|
171
205
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
deferredChargeNotice,
|
|
4
|
+
isDeferredSettlement,
|
|
5
|
+
isManualConfirmSuccess,
|
|
6
|
+
MANUAL_CONFIRM_SUCCESS_STATUSES,
|
|
7
|
+
} from "../stripeConfirmOutcome";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one-line fix from the curated-access design (section 6.2, step 2): a
|
|
11
|
+
* deferred charge leaves the PaymentIntent at `requires_capture`, and the
|
|
12
|
+
* manual-mode branch of `ForgeStripePayment` used to read anything that was
|
|
13
|
+
* not `succeeded`/`processing` as "Payment did not complete". The decision is
|
|
14
|
+
* a pure function now, so the renderer cannot drift back.
|
|
15
|
+
*/
|
|
16
|
+
describe("isManualConfirmSuccess", () => {
|
|
17
|
+
it("REGRESSION: counts an authorised, uncaptured intent as the fan being done", () => {
|
|
18
|
+
expect(isManualConfirmSuccess("requires_capture")).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps the two statuses it always accepted", () => {
|
|
22
|
+
expect(isManualConfirmSuccess("succeeded")).toBe(true);
|
|
23
|
+
expect(isManualConfirmSuccess("processing")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("still refuses every status that means the fan is not done", () => {
|
|
27
|
+
for (const status of ["requires_payment_method", "requires_confirmation", "requires_action", "canceled"]) {
|
|
28
|
+
expect(isManualConfirmSuccess(status)).toBe(false);
|
|
29
|
+
}
|
|
30
|
+
expect(isManualConfirmSuccess(undefined)).toBe(false);
|
|
31
|
+
expect(isManualConfirmSuccess(null)).toBe(false);
|
|
32
|
+
expect(isManualConfirmSuccess("")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("exposes the same set the renderer reads, so a custom renderer can share it", () => {
|
|
36
|
+
expect([...MANUAL_CONFIRM_SUCCESS_STATUSES].sort()).toEqual(["processing", "requires_capture", "succeeded"]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("isDeferredSettlement", () => {
|
|
41
|
+
it("is true only for an explicit deferred charge", () => {
|
|
42
|
+
expect(isDeferredSettlement("deferred")).toBe(true);
|
|
43
|
+
expect(isDeferredSettlement("immediate")).toBe(false);
|
|
44
|
+
// An older API build sends nothing, and nothing must render as a sale.
|
|
45
|
+
expect(isDeferredSettlement(undefined)).toBe(false);
|
|
46
|
+
expect(isDeferredSettlement(null)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("deferredChargeNotice", () => {
|
|
51
|
+
it("promises a visible hold only when the provider actually places one", () => {
|
|
52
|
+
expect(deferredChargeNotice("authorization")).toBe("authorization");
|
|
53
|
+
expect(deferredChargeNotice("saved_instrument")).toBe("saved_instrument");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("falls back to the quieter line for an unknown strategy", () => {
|
|
57
|
+
// Promising a hold that never appears is the larger lie of the two.
|
|
58
|
+
expect(deferredChargeNotice(undefined)).toBe("saved_instrument");
|
|
59
|
+
expect(deferredChargeNotice(null)).toBe("saved_instrument");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DeferredChargeStrategy, PaymentSettlement } from "../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The PaymentIntent statuses `confirmPayment` can leave behind that mean "the
|
|
5
|
+
* fan's part is done", in the manual (confirm-in-place) branch of
|
|
6
|
+
* `ForgeStripePayment`.
|
|
7
|
+
*
|
|
8
|
+
* succeeded charged
|
|
9
|
+
* processing a delayed-notification method (bank debit); charged later
|
|
10
|
+
* requires_capture AUTHORISED, not captured: a deferred charge. The platform
|
|
11
|
+
* captures it when the organiser approves a ticket request
|
|
12
|
+
* (curated access, feature 3) and cancels it otherwise.
|
|
13
|
+
*
|
|
14
|
+
* Before `requires_capture` was here a request checkout confirmed the card,
|
|
15
|
+
* Stripe answered exactly what a manual-capture intent answers, and the
|
|
16
|
+
* renderer told the fan "Payment did not complete" while the order was already
|
|
17
|
+
* on its way to `pending_approval`. Anything else (`requires_payment_method`,
|
|
18
|
+
* `requires_action` that did not resolve, `canceled`) is not done.
|
|
19
|
+
*/
|
|
20
|
+
export const MANUAL_CONFIRM_SUCCESS_STATUSES: ReadonlySet<string> = new Set([
|
|
21
|
+
"succeeded",
|
|
22
|
+
"processing",
|
|
23
|
+
"requires_capture",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Does this post-confirm PaymentIntent status count as the fan having finished? */
|
|
27
|
+
export function isManualConfirmSuccess(status: string | null | undefined): boolean {
|
|
28
|
+
return !!status && MANUAL_CONFIRM_SUCCESS_STATUSES.has(status);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A deferred charge: the card is committed now and taken only on approval. */
|
|
32
|
+
export function isDeferredSettlement(settlement: PaymentSettlement | null | undefined): boolean {
|
|
33
|
+
return settlement === "deferred";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which explanation goes under the pay button on a deferred charge.
|
|
38
|
+
*
|
|
39
|
+
* `authorization` places a hold the fan will SEE on their statement, so the
|
|
40
|
+
* line has to say so or the first thing they notice is a pending charge they
|
|
41
|
+
* were promised would not happen. `saved_instrument` (a provider that stores
|
|
42
|
+
* the card and charges it later) takes nothing, and the line says only that.
|
|
43
|
+
* An unknown strategy is treated as the quieter of the two: promising a hold
|
|
44
|
+
* that never appears is the lesser lie.
|
|
45
|
+
*/
|
|
46
|
+
export function deferredChargeNotice(
|
|
47
|
+
strategy: DeferredChargeStrategy | null | undefined,
|
|
48
|
+
): "authorization" | "saved_instrument" {
|
|
49
|
+
return strategy === "authorization" ? "authorization" : "saved_instrument";
|
|
50
|
+
}
|
|
@@ -16,6 +16,12 @@ import { previewDiagnosticsEnabled, resolvePreviewDiagnostics } from "./diagnost
|
|
|
16
16
|
import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
|
|
17
17
|
import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
|
|
18
18
|
import { captureLandingFromUrl, postLandingBeacon } from "../../utils/landing";
|
|
19
|
+
import { registerHostRuntime } from "../../runtime/hostRuntime";
|
|
20
|
+
|
|
21
|
+
// Module scope, not a hook. The hosted `/i/*` bundle can be imported by the
|
|
22
|
+
// very first page a visitor lands on, so the runtime it reads has to be
|
|
23
|
+
// published before any component renders, not during one.
|
|
24
|
+
registerHostRuntime();
|
|
19
25
|
|
|
20
26
|
// Captures both attribution signals on first paint: the tracked-link `?tn_ref`
|
|
21
27
|
// (stripped afterwards) and the first-touch landing snapshot (all utm/click-id
|