@tribe-nest/forge 3.26.0 → 3.31.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 +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCart, type CartItem, type TicketCartItem } from "../../../contexts/CartContext";
|
|
3
|
+
import { useResumeCheckout, type ResumeLine, type ResumedCheckout } from "../../../data/queries/useCheckouts";
|
|
4
|
+
import { rememberResumedCoupon } from "./resumedCoupon";
|
|
5
|
+
import { ProductDeliveryType } from "../../../types/models";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The landing behaviour of a cart-recovery link, shared by both storefronts.
|
|
9
|
+
*
|
|
10
|
+
* `apps/client` and a Forge code website draw this page completely differently
|
|
11
|
+
* (Tailwind against `themeSettings` on one, inline styles against the Forge
|
|
12
|
+
* theme on the other) and they must not decide DIFFERENTLY what a resumed
|
|
13
|
+
* basket does. So the exchange, the restore and the arithmetic live here once
|
|
14
|
+
* and each stack owns only how it looks, exactly as `ticketAvailability` and
|
|
15
|
+
* `useClaimPassTransfer` already do.
|
|
16
|
+
*
|
|
17
|
+
* ## What lands in the basket, and what does not
|
|
18
|
+
*
|
|
19
|
+
* Only the lines the server says can still be bought. Every OTHER line is
|
|
20
|
+
* still returned to the caller in `unavailableLines`, because the two failure
|
|
21
|
+
* modes here are asymmetric and both are bad:
|
|
22
|
+
*
|
|
23
|
+
* - Silently dropping a line is the worst outcome. The buyer came back for the
|
|
24
|
+
* thing they left, the basket is quietly one item lighter, and they find out
|
|
25
|
+
* after paying (or never).
|
|
26
|
+
* - Restoring a line nobody will sell them is the second worst. They reach the
|
|
27
|
+
* payment screen and are refused, which reads as a broken shop.
|
|
28
|
+
*
|
|
29
|
+
* Showing it, greyed out, with the reason next to it is neither.
|
|
30
|
+
*
|
|
31
|
+
* ## An add-on never lands without the ticket it hangs off
|
|
32
|
+
*
|
|
33
|
+
* A product line carrying `attachedTo` was added from an event's add-on block
|
|
34
|
+
* and is only sellable next to that event's own ticket line. If the ticket
|
|
35
|
+
* cannot be restored (sold out, withdrawn) the add-on cannot either: the server
|
|
36
|
+
* refuses a bundle whose add-on has no base, so restoring it alone builds a
|
|
37
|
+
* basket that fails at the payment screen with nothing on the page saying why.
|
|
38
|
+
*
|
|
39
|
+
* The base has to come from THIS payload, not from whatever is in the basket
|
|
40
|
+
* already. The buckets below are derived from the server answer alone, so they
|
|
41
|
+
* cannot change under the page once the restore has run.
|
|
42
|
+
*
|
|
43
|
+
* Those add-ons come back in `droppedLines` rather than disappearing, for the
|
|
44
|
+
* same reason unavailable lines do: a basket that is quietly one item lighter
|
|
45
|
+
* is the worst of the outcomes above.
|
|
46
|
+
*
|
|
47
|
+
* ## Courses and coaching are not basket items
|
|
48
|
+
*
|
|
49
|
+
* The cart holds products and event tickets. A course or a coaching session is
|
|
50
|
+
* bought through its own page, so those lines come back as `continueLines`:
|
|
51
|
+
* nothing is restored and the caller draws a link back to the page where the
|
|
52
|
+
* purchase resumes. Reporting them as "restored" would be a lie, and dropping
|
|
53
|
+
* them would strand the only recovery path those two pillars have.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
// Re-exported so a caller reaches the whole vocabulary from `forge/ui` without
|
|
57
|
+
// also importing the data layer.
|
|
58
|
+
export type {
|
|
59
|
+
ResumeLine,
|
|
60
|
+
ResumedCheckout,
|
|
61
|
+
ResumeLineOption,
|
|
62
|
+
ResumeLineUnavailableReason,
|
|
63
|
+
} from "../../../data/queries/useCheckouts";
|
|
64
|
+
|
|
65
|
+
export type CartResumeStatus = "loading" | "restored" | "empty" | "error";
|
|
66
|
+
|
|
67
|
+
export type UseCartResumeOptions = {
|
|
68
|
+
/** The `?token=` from the recovery link. Absent or blank means an invalid link. */
|
|
69
|
+
token?: string | null;
|
|
70
|
+
/**
|
|
71
|
+
* Put the basket back as soon as the exchange answers. On by default: the
|
|
72
|
+
* point of the link is that the buyer arrives with a full basket, not with a
|
|
73
|
+
* button that promises one.
|
|
74
|
+
*/
|
|
75
|
+
autoRestore?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type UseCartResumeResult = {
|
|
79
|
+
status: CartResumeStatus;
|
|
80
|
+
/** The whole server answer, for a caller that wants a field this hook does not surface. */
|
|
81
|
+
resumed: ResumedCheckout | null;
|
|
82
|
+
/** Lines that went back in the basket. */
|
|
83
|
+
restoredLines: ResumeLine[];
|
|
84
|
+
/** Lines that could not, each with its `unavailableReason`. Draw these. */
|
|
85
|
+
unavailableLines: ResumeLine[];
|
|
86
|
+
/**
|
|
87
|
+
* Add-ons the shop would still sell, left out because the event ticket they
|
|
88
|
+
* hang off was not restored. Draw these too: they were in the basket the
|
|
89
|
+
* buyer left, and they are not in the one they got back.
|
|
90
|
+
*/
|
|
91
|
+
droppedLines: ResumeLine[];
|
|
92
|
+
/** Course and coaching lines: not basket items, link the buyer onward. */
|
|
93
|
+
continueLines: ResumeLine[];
|
|
94
|
+
/** Restored lines whose price moved while the buyer was away. */
|
|
95
|
+
repricedLines: ResumeLine[];
|
|
96
|
+
/** Units restored. */
|
|
97
|
+
itemCount: number;
|
|
98
|
+
/** Minor units, restored lines only, at today's prices and before discount. */
|
|
99
|
+
subtotalCents: number;
|
|
100
|
+
currency: string;
|
|
101
|
+
/** A code to re-apply, already stashed for the checkout page. Display it. */
|
|
102
|
+
couponCode: string | null;
|
|
103
|
+
/** Set when the link itself failed (missing, expired, another artist's). */
|
|
104
|
+
error: string | null;
|
|
105
|
+
/** Restore by hand. A no-op once the basket has been restored. */
|
|
106
|
+
restore: () => void;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A resumed product line as the cart stores it.
|
|
111
|
+
*
|
|
112
|
+
* Exported so the translation can be tested without a DOM: every field here is
|
|
113
|
+
* one the checkout later branches on, and getting `deliveryType` wrong ships an
|
|
114
|
+
* order with nowhere to send it.
|
|
115
|
+
*/
|
|
116
|
+
export function resumeLineToCartItem(line: ResumeLine): CartItem | null {
|
|
117
|
+
if (!line.productId || !line.productVariantId) return null;
|
|
118
|
+
return {
|
|
119
|
+
productId: line.productId,
|
|
120
|
+
productVariantId: line.productVariantId,
|
|
121
|
+
title: line.title,
|
|
122
|
+
price: line.price,
|
|
123
|
+
coverImage: line.coverImage ?? undefined,
|
|
124
|
+
isGift: !!line.isGift,
|
|
125
|
+
recipientName: line.recipientName ?? undefined,
|
|
126
|
+
recipientEmail: line.recipientEmail ?? undefined,
|
|
127
|
+
recipientMessage: line.recipientMessage ?? undefined,
|
|
128
|
+
/**
|
|
129
|
+
* The buyer chose this quantity once; restoring it as 1 would make them
|
|
130
|
+
* re-count. `canIncreaseQuantity` follows the ordinary shop default so the
|
|
131
|
+
* cart's stepper behaves as it does for any other line.
|
|
132
|
+
*/
|
|
133
|
+
quantity: line.quantity,
|
|
134
|
+
canIncreaseQuantity: true,
|
|
135
|
+
payWhatYouWant: !!line.payWhatYouWant,
|
|
136
|
+
options: line.options ?? undefined,
|
|
137
|
+
/**
|
|
138
|
+
* Never guessed. Checkout reads shipping-or-not off this field, and a
|
|
139
|
+
* physical line restored as non-physical means an order arrives with
|
|
140
|
+
* nowhere to send it. The server always sends it for a line it declared
|
|
141
|
+
* available; `Digital` here would be a bug upstream, not a default.
|
|
142
|
+
*/
|
|
143
|
+
deliveryType: line.deliveryType ?? ProductDeliveryType.Digital,
|
|
144
|
+
attachedTo: line.attachedTo ?? undefined,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Ticket lines regrouped into the per-event shape the cart holds. */
|
|
149
|
+
export function resumeLinesToTicketItems(lines: ResumeLine[]): TicketCartItem[] {
|
|
150
|
+
const byEvent = new Map<string, TicketCartItem>();
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
if (!line.eventId || !line.ticketId) continue;
|
|
153
|
+
const existing = byEvent.get(line.eventId) ?? {
|
|
154
|
+
eventId: line.eventId,
|
|
155
|
+
eventSlug: line.eventSlug ?? "",
|
|
156
|
+
eventTitle: line.eventTitle ?? line.title,
|
|
157
|
+
coverImage: line.coverImage ?? undefined,
|
|
158
|
+
tickets: {},
|
|
159
|
+
ticketMeta: {},
|
|
160
|
+
bookingFee: line.bookingFee ?? null,
|
|
161
|
+
};
|
|
162
|
+
existing.tickets[line.ticketId] = (existing.tickets[line.ticketId] ?? 0) + line.quantity;
|
|
163
|
+
existing.ticketMeta[line.ticketId] = {
|
|
164
|
+
title: line.title,
|
|
165
|
+
// On a pay-what-you-want tier the buyer's own amount is what the cart
|
|
166
|
+
// shows and what the bundle charges, so it wins over the tier's floor.
|
|
167
|
+
price: line.pwywAmount ?? line.price,
|
|
168
|
+
...(line.pwywAmount != null ? { pwywAmount: line.pwywAmount } : {}),
|
|
169
|
+
};
|
|
170
|
+
byEvent.set(line.eventId, existing);
|
|
171
|
+
}
|
|
172
|
+
return [...byEvent.values()];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const CONTINUE_TYPES = new Set(["course", "coaching"]);
|
|
176
|
+
|
|
177
|
+
/** The four buckets a resumed payload splits into. */
|
|
178
|
+
export type ResumePartition = {
|
|
179
|
+
restoredLines: ResumeLine[];
|
|
180
|
+
unavailableLines: ResumeLine[];
|
|
181
|
+
continueLines: ResumeLine[];
|
|
182
|
+
droppedLines: ResumeLine[];
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Split the server's lines into what goes back in the basket and what only gets
|
|
187
|
+
* drawn. Pure, and exported, because this is the decision the whole page rests
|
|
188
|
+
* on and it must be assertable without a DOM.
|
|
189
|
+
*
|
|
190
|
+
* The subtle bucket is `droppedLines`. An add-on is only sellable next to its
|
|
191
|
+
* base, so a base that is not itself being restored takes the add-on with it.
|
|
192
|
+
* "Being restored" is checked against the fields `resumeLinesToTicketItems`
|
|
193
|
+
* actually needs (`eventId` and `ticketId`), so a ticket line that function
|
|
194
|
+
* would skip does not count as a base either.
|
|
195
|
+
*/
|
|
196
|
+
export function partitionResumeLines(lines: ResumeLine[]): ResumePartition {
|
|
197
|
+
const unavailableLines = lines.filter((line) => !line.available);
|
|
198
|
+
const available = lines.filter((line) => line.available);
|
|
199
|
+
const continueLines = available.filter((line) => CONTINUE_TYPES.has(line.type));
|
|
200
|
+
const restorable = available.filter((line) => !CONTINUE_TYPES.has(line.type));
|
|
201
|
+
|
|
202
|
+
const baseEventIds = new Set(
|
|
203
|
+
restorable
|
|
204
|
+
.filter((line) => line.type === "event_ticket" && line.eventId && line.ticketId)
|
|
205
|
+
.map((line) => line.eventId as string),
|
|
206
|
+
);
|
|
207
|
+
const isOrphanedAddon = (line: ResumeLine) =>
|
|
208
|
+
line.type === "product" &&
|
|
209
|
+
line.attachedTo?.type === "event" &&
|
|
210
|
+
!baseEventIds.has(line.attachedTo.entityId);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
restoredLines: restorable.filter((line) => !isOrphanedAddon(line)),
|
|
214
|
+
unavailableLines,
|
|
215
|
+
continueLines,
|
|
216
|
+
droppedLines: restorable.filter(isOrphanedAddon),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What this line will actually cost per unit, in minor units. */
|
|
221
|
+
export function effectiveUnitCents(line: ResumeLine): number {
|
|
222
|
+
return line.pwywAmount != null ? Math.round(line.pwywAmount * 100) : line.unitPriceCents;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function useCartResume(options: UseCartResumeOptions): UseCartResumeResult {
|
|
226
|
+
const { token, autoRestore = true } = options;
|
|
227
|
+
const { restoreCart, isReady } = useCart();
|
|
228
|
+
const { mutateAsync } = useResumeCheckout();
|
|
229
|
+
|
|
230
|
+
const [resumed, setResumed] = useState<ResumedCheckout | null>(null);
|
|
231
|
+
const [error, setError] = useState<string | null>(null);
|
|
232
|
+
const [restored, setRestored] = useState(false);
|
|
233
|
+
/**
|
|
234
|
+
* One exchange per token. The endpoint stamps the arrival that cart-recovery
|
|
235
|
+
* attributes on, so firing it twice is not merely wasteful, and StrictMode
|
|
236
|
+
* runs every effect twice in development.
|
|
237
|
+
*/
|
|
238
|
+
const requestedFor = useRef<string | null>(null);
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const value = (token ?? "").trim();
|
|
242
|
+
if (!value) {
|
|
243
|
+
setError("missing_token");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (requestedFor.current === value) return;
|
|
247
|
+
requestedFor.current = value;
|
|
248
|
+
|
|
249
|
+
let cancelled = false;
|
|
250
|
+
void mutateAsync({ token: value })
|
|
251
|
+
.then((data) => {
|
|
252
|
+
if (cancelled) return;
|
|
253
|
+
setResumed(data);
|
|
254
|
+
setError(null);
|
|
255
|
+
// Stashed even when the caller does not auto-restore, so the code
|
|
256
|
+
// survives the walk from this page to checkout either way.
|
|
257
|
+
rememberResumedCoupon(data.couponCode);
|
|
258
|
+
})
|
|
259
|
+
.catch((err: unknown) => {
|
|
260
|
+
if (cancelled) return;
|
|
261
|
+
setError(
|
|
262
|
+
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "invalid_link",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return () => {
|
|
267
|
+
cancelled = true;
|
|
268
|
+
};
|
|
269
|
+
}, [token, mutateAsync]);
|
|
270
|
+
|
|
271
|
+
const { restoredLines, unavailableLines, continueLines, droppedLines } = useMemo(
|
|
272
|
+
() => partitionResumeLines(resumed?.lines ?? []),
|
|
273
|
+
[resumed],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const repricedLines = useMemo(
|
|
277
|
+
() => restoredLines.filter((line) => line.priceChanged),
|
|
278
|
+
[restoredLines],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const restore = useCallback(() => {
|
|
282
|
+
/**
|
|
283
|
+
* `isReady` is not belt and braces. `CartProvider` hydrates from
|
|
284
|
+
* localStorage in a mount effect and that effect REPLACES the whole list,
|
|
285
|
+
* so anything written before it lands is thrown away. The network round
|
|
286
|
+
* trip makes that practically impossible, and "practically impossible"
|
|
287
|
+
* silent data loss is the kind that surfaces once, on somebody's phone, and
|
|
288
|
+
* is never reproduced.
|
|
289
|
+
*/
|
|
290
|
+
if (!isReady || restored || restoredLines.length === 0) return;
|
|
291
|
+
restoreCart({
|
|
292
|
+
items: restoredLines
|
|
293
|
+
.filter((line) => line.type === "product")
|
|
294
|
+
.map(resumeLineToCartItem)
|
|
295
|
+
.filter((item): item is CartItem => item !== null),
|
|
296
|
+
tickets: resumeLinesToTicketItems(restoredLines.filter((line) => line.type === "event_ticket")),
|
|
297
|
+
});
|
|
298
|
+
setRestored(true);
|
|
299
|
+
/**
|
|
300
|
+
* `isReady` belongs here. Without it the callback keeps the `false` it
|
|
301
|
+
* closed over on the first render, React hands the same stale function back
|
|
302
|
+
* on every later one, and the effect below never re-runs once hydration
|
|
303
|
+
* finishes: the guard stops being a wait and becomes a permanent refusal on
|
|
304
|
+
* exactly the slow load it was written for.
|
|
305
|
+
*/
|
|
306
|
+
}, [isReady, restored, restoredLines, restoreCart]);
|
|
307
|
+
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (autoRestore) restore();
|
|
310
|
+
}, [autoRestore, restore]);
|
|
311
|
+
|
|
312
|
+
const status: CartResumeStatus = error
|
|
313
|
+
? "error"
|
|
314
|
+
: !resumed
|
|
315
|
+
? "loading"
|
|
316
|
+
: restoredLines.length === 0 && continueLines.length === 0
|
|
317
|
+
? "empty"
|
|
318
|
+
: "restored";
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
status,
|
|
322
|
+
resumed,
|
|
323
|
+
restoredLines,
|
|
324
|
+
unavailableLines,
|
|
325
|
+
continueLines,
|
|
326
|
+
droppedLines,
|
|
327
|
+
repricedLines,
|
|
328
|
+
itemCount: restoredLines.reduce((sum, line) => sum + line.quantity, 0),
|
|
329
|
+
// The figure the CART will show, so a pay-what-you-want tier counts the
|
|
330
|
+
// buyer's own amount rather than the tier's floor.
|
|
331
|
+
subtotalCents: restoredLines.reduce(
|
|
332
|
+
(sum, line) => sum + effectiveUnitCents(line) * line.quantity,
|
|
333
|
+
0,
|
|
334
|
+
),
|
|
335
|
+
currency: resumed?.currency ?? "USD",
|
|
336
|
+
couponCode: resumed?.couponCode ?? null,
|
|
337
|
+
error,
|
|
338
|
+
restore,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "../../../data/queries/useCheckouts";
|
|
21
21
|
import { bundleCouponSummary, bundleReturnUrl } from "./bundleCoupon";
|
|
22
22
|
import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
|
|
23
|
+
import { takeResumedCoupon } from "./resumedCoupon";
|
|
23
24
|
import { useInventoryHold } from "./useInventoryHold";
|
|
24
25
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
25
26
|
import { readLanding } from "../../../utils/landing";
|
|
@@ -191,8 +192,14 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
191
192
|
[selectedShippingRates],
|
|
192
193
|
);
|
|
193
194
|
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
/**
|
|
196
|
+
* A code the buyer arrived with from a recovery link, prefilled but NOT
|
|
197
|
+
* applied: they still press Apply and the server re-evaluates it against
|
|
198
|
+
* today. The lazy initialiser consumes the stash, so a code cannot linger and
|
|
199
|
+
* prefill an unrelated checkout later in the same tab.
|
|
200
|
+
*/
|
|
201
|
+
const [couponCode, setCouponCode] = useState(takeResumedCoupon);
|
|
202
|
+
const [showCouponInput, setShowCouponInput] = useState(() => couponCode.length > 0);
|
|
196
203
|
const [couponError, setCouponError] = useState("");
|
|
197
204
|
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
|
198
205
|
const [appliedCoupon, setAppliedCoupon] = useState<AppliedCoupon | null>(null);
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
|
|
19
19
|
import { useInventoryHold } from "../checkout/useInventoryHold";
|
|
20
20
|
import { computeBookingFeeAmount } from "../../format/bookingFee";
|
|
21
|
-
import {
|
|
21
|
+
import { checkoutAmounts, isPayWhatYouWant, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
|
|
22
22
|
import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
|
|
23
23
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
24
24
|
import { readLanding } from "../../../utils/landing";
|
|
@@ -151,18 +151,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
151
151
|
* they have not. This is what gets SENT, so an untouched box is not the same
|
|
152
152
|
* as choosing the minimum: the suggestion is the default offer.
|
|
153
153
|
*/
|
|
154
|
-
const effectiveAmounts = useMemo(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
? resolveUnitPrice(ticket, pwywAmounts[ticket.id])
|
|
162
|
-
: pwywDefaultAmount(ticket);
|
|
163
|
-
}
|
|
164
|
-
return out;
|
|
165
|
-
}, [event, selectedTickets, pwywAmounts]);
|
|
154
|
+
const effectiveAmounts = useMemo(
|
|
155
|
+
() =>
|
|
156
|
+
event
|
|
157
|
+
? checkoutAmounts({ tickets: event.tickets, quantities: selectedTickets, chosen: pwywAmounts })
|
|
158
|
+
: ({} as Record<string, number>),
|
|
159
|
+
[event, selectedTickets, pwywAmounts],
|
|
160
|
+
);
|
|
166
161
|
|
|
167
162
|
/**
|
|
168
163
|
* `paid` is what the buyer owes for tickets; `floor` is the same cart valued
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -29,6 +29,24 @@ export {
|
|
|
29
29
|
type InventoryHoldState,
|
|
30
30
|
type UseInventoryHoldOptions,
|
|
31
31
|
} from "./checkout/useInventoryHold";
|
|
32
|
+
// Cart recovery: what a resume link does when the buyer clicks it. Shared so
|
|
33
|
+
// `apps/client` and a Forge code site cannot decide differently.
|
|
34
|
+
export {
|
|
35
|
+
useCartResume,
|
|
36
|
+
effectiveUnitCents,
|
|
37
|
+
partitionResumeLines,
|
|
38
|
+
resumeLineToCartItem,
|
|
39
|
+
resumeLinesToTicketItems,
|
|
40
|
+
type ResumePartition,
|
|
41
|
+
type UseCartResumeOptions,
|
|
42
|
+
type UseCartResumeResult,
|
|
43
|
+
type CartResumeStatus,
|
|
44
|
+
type ResumeLine,
|
|
45
|
+
type ResumedCheckout,
|
|
46
|
+
type ResumeLineOption,
|
|
47
|
+
type ResumeLineUnavailableReason,
|
|
48
|
+
} from "./checkout/useCartResume";
|
|
49
|
+
export { rememberResumedCoupon, takeResumedCoupon } from "./checkout/resumedCoupon";
|
|
32
50
|
export {
|
|
33
51
|
isHoldExpiredError,
|
|
34
52
|
holdExpiredMessage,
|
|
@@ -81,6 +99,20 @@ export {
|
|
|
81
99
|
export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
|
|
82
100
|
export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
|
|
83
101
|
export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
|
|
102
|
+
export {
|
|
103
|
+
broadcastListStatus,
|
|
104
|
+
broadcastSessionKey,
|
|
105
|
+
hasBroadcastEnded,
|
|
106
|
+
isPassValidated,
|
|
107
|
+
minTicketPrice,
|
|
108
|
+
needsPassValidation,
|
|
109
|
+
selectBroadcastScreen,
|
|
110
|
+
type BroadcastListStatus,
|
|
111
|
+
type BroadcastListStatusText,
|
|
112
|
+
type BroadcastScreen,
|
|
113
|
+
type BroadcastScreenInput,
|
|
114
|
+
type BroadcastView,
|
|
115
|
+
} from "./broadcast/broadcastState";
|
|
84
116
|
export { useChatRoom } from "./chat/useChatRoom";
|
|
85
117
|
export { useMessageThread, type UseMessageThreadResult } from "./chat/useMessageThread";
|
|
86
118
|
export { useChatAttachmentUpload, type PendingAttachment } from "./chat/useChatAttachmentUpload";
|