@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.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. 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
- const [couponCode, setCouponCode] = useState("");
195
- const [showCouponInput, setShowCouponInput] = useState(false);
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 { isPayWhatYouWant, pwywDefaultAmount, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
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
- if (!event) return {} as Record<string, number>;
156
- const out: Record<string, number> = {};
157
- for (const ticket of event.tickets) {
158
- if (!isPayWhatYouWant(ticket) || (selectedTickets[ticket.id] ?? 0) <= 0) continue;
159
- out[ticket.id] =
160
- pwywAmounts[ticket.id] != null
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
@@ -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";