@tribe-nest/forge 3.57.0 → 3.59.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 (71) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -2
  6. package/src/data/queries/useEvents.ts +68 -0
  7. package/src/data/queries/useMyBookings.ts +16 -0
  8. package/src/data/queries/useNotifications.ts +1 -1
  9. package/src/i18n/de.json +79 -0
  10. package/src/i18n/en.json +79 -0
  11. package/src/index.ts +1 -0
  12. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  13. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  14. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  15. package/src/server/index.ts +1 -1
  16. package/src/server/pwa.ts +1 -1
  17. package/src/types/models.ts +10 -0
  18. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  19. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  20. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  21. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  22. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  23. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  24. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  25. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  26. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  27. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  28. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  29. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  30. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  31. package/src/ui/headless/event/useEventSeating.ts +233 -0
  32. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  33. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  34. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  35. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  36. package/src/ui/headless/index.ts +25 -1
  37. package/src/ui/index.ts +7 -0
  38. package/src/ui/media/CallStage.tsx +107 -10
  39. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  40. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  41. package/src/ui/media/_tests/callState.spec.ts +103 -1
  42. package/src/ui/media/callBreakouts.ts +57 -0
  43. package/src/ui/media/callState.ts +83 -23
  44. package/src/ui/media/index.ts +11 -0
  45. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  46. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  47. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  48. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  49. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  50. package/src/ui/shell/TribeNestApp.tsx +1 -1
  51. package/src/ui/styled/AccountDashboard.tsx +5 -0
  52. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  53. package/src/ui/styled/Cart.tsx +211 -135
  54. package/src/ui/styled/Checkout.tsx +163 -32
  55. package/src/ui/styled/EmailListForm.tsx +7 -9
  56. package/src/ui/styled/EventTickets.tsx +143 -4
  57. package/src/ui/styled/PhoneInput.tsx +127 -0
  58. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  59. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  60. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  61. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  62. package/src/ui/styled/forge-utilities.css +67 -0
  63. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  64. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  65. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  66. package/src/ui/styled/seating/paint.ts +79 -0
  67. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  68. package/src/ui/styled/seating/viewport.ts +86 -0
  69. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  70. package/src/utils/engagementTracker.ts +73 -0
  71. package/src/utils/landing.ts +1 -1
@@ -25,8 +25,10 @@ export function CallCenterNotice({
25
25
  onRetry: () => void;
26
26
  }) {
27
27
  const detail = notice.detailKey ? t(notice.detailKey) : notice.detailText;
28
- const busy = notice.kind === "joining";
29
- const Icon = notice.kind === "joining" ? Loader2 : notice.kind === "waiting" || notice.kind === "everyone_left" ? Users : PhoneOff;
28
+ // A breakout move is a reconnect the person asked for (or was sent on), so
29
+ // it spins like joining does: something is happening, nothing is wrong.
30
+ const busy = notice.kind === "joining" || notice.kind === "moving";
31
+ const Icon = busy ? Loader2 : notice.kind === "waiting" || notice.kind === "everyone_left" ? Users : PhoneOff;
30
32
 
31
33
  return (
32
34
  <div
@@ -59,7 +61,7 @@ export function CallCenterNotice({
59
61
  color={notice.kind === "failed" ? tokens.danger : tokens.stageMuted}
60
62
  style={busy ? { animation: "forge-call-spin 1.2s linear infinite" } : undefined}
61
63
  />
62
- <strong style={{ fontSize: "1.05rem" }}>{t(notice.headlineKey)}</strong>
64
+ <strong style={{ fontSize: "1.05rem" }}>{t(notice.headlineKey, notice.headlineVars)}</strong>
63
65
  {detail && <span style={{ fontSize: "0.875rem", color: tokens.stageMuted, maxWidth: "28rem" }}>{detail}</span>}
64
66
  {notice.canRetry && (
65
67
  <button
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
2
2
  import {
3
3
  Circle,
4
+ DoorOpen,
4
5
  Hand,
5
6
  MessageSquare,
6
7
  Mic,
@@ -10,6 +11,7 @@ import {
10
11
  PhoneOff,
11
12
  SmilePlus,
12
13
  Square,
14
+ Undo2,
13
15
  Users,
14
16
  Video,
15
17
  VideoOff,
@@ -73,6 +75,18 @@ export type CallControlsBarProps = {
73
75
  onTogglePeople: () => void;
74
76
  peopleOpen: boolean;
75
77
  chatOpen: boolean;
78
+ /**
79
+ * Breakout rooms. All absent by default, and then nothing here is drawn:
80
+ * the bar renders pixel for pixel as it did before rooms existed.
81
+ */
82
+ roomsAvailable?: boolean | undefined;
83
+ roomsOpen?: boolean | undefined;
84
+ onToggleRooms?: (() => void) | undefined;
85
+ /** This seat is in a breakout room, so Return to main is offered. */
86
+ inBreakout?: boolean | undefined;
87
+ onReturnToMain?: (() => void) | undefined;
88
+ /** The self-move is in flight. Holds the button rather than letting it be pressed twice. */
89
+ movePending?: boolean | undefined;
76
90
  /** Presses of any control unlock the chime AudioContext. */
77
91
  onGesture?: (() => void) | undefined;
78
92
  };
@@ -372,7 +386,39 @@ export function CallControlsBar(props: CallControlsBarProps) {
372
386
  )}
373
387
  </div>
374
388
 
389
+ {props.inBreakout && props.onReturnToMain && (
390
+ <button
391
+ type="button"
392
+ data-testid="return-to-main"
393
+ onClick={() => {
394
+ gesture();
395
+ props.onReturnToMain?.();
396
+ }}
397
+ disabled={props.movePending}
398
+ aria-busy={props.movePending}
399
+ style={barButton(tokens, false, props.movePending)}
400
+ >
401
+ <Undo2 width={16} height={16} aria-hidden="true" />
402
+ {t("forge.call_stage.breakouts.return_to_main")}
403
+ </button>
404
+ )}
405
+
375
406
  <span style={{ marginLeft: "auto", display: "inline-flex", gap: "0.5rem" }}>
407
+ {props.roomsAvailable && props.onToggleRooms && (
408
+ <button
409
+ type="button"
410
+ data-testid="toggle-rooms"
411
+ onClick={() => {
412
+ gesture();
413
+ props.onToggleRooms?.();
414
+ }}
415
+ aria-pressed={!!props.roomsOpen}
416
+ style={barButton(tokens, !!props.roomsOpen)}
417
+ >
418
+ <DoorOpen width={16} height={16} aria-hidden="true" />
419
+ {t("forge.call_stage.breakouts.button")}
420
+ </button>
421
+ )}
376
422
  {props.chatAvailable && (
377
423
  <button
378
424
  type="button"
@@ -250,7 +250,7 @@ function ConnectionPill({ status, tokens, t }: { status: CallStatus; tokens: Sta
250
250
  {status.tone === "live" && (
251
251
  <span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: tokens.accent, display: "inline-block" }} />
252
252
  )}
253
- <span style={{ fontWeight: status.tone === "failed" ? 600 : 400 }}>{t(status.headlineKey)}</span>
253
+ <span style={{ fontWeight: status.tone === "failed" ? 600 : 400 }}>{t(status.headlineKey, status.headlineVars)}</span>
254
254
  {detail && status.tone !== "live" && <span>{detail}</span>}
255
255
  </span>
256
256
  );
@@ -28,7 +28,7 @@ registerHostRuntime();
28
28
  // params + referrer, kept in the URL for page-level ad pixels). On a FRESH
29
29
  // landing it also fires the visit-level beacon so the sale-less visit is counted.
30
30
  // Client-only, renders null. Lives in the shell so every code website gets ad
31
- // attribution without editing the tenant-owned __root. See docs/ads-attribution-initiative.md.
31
+ // attribution without editing the tenant-owned __root. See docs/archive/shipped/ads-attribution-initiative.md.
32
32
  function AttributionCapture({ enabled, apiUrl, profileId }: { enabled: boolean; apiUrl?: string; profileId?: string }) {
33
33
  useEffect(() => {
34
34
  if (!enabled) return;
@@ -1351,6 +1351,11 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1351
1351
  {booking.cancellation.terms && (
1352
1352
  <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.cancellation.terms}</div>
1353
1353
  )}
1354
+ {/* A reschedule rule of its own. When it follows the cancellation window
1355
+ the sentence above already says it, so nothing is repeated. */}
1356
+ {booking.reschedulePolicy && !booking.reschedulePolicy.followsCancellation && booking.reschedulePolicy.description && (
1357
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.reschedulePolicy.description}</div>
1358
+ )}
1354
1359
 
1355
1360
  {/*
1356
1361
  The action row. Both controls are drawn from the SERVER's flags —
@@ -1,4 +1,4 @@
1
- import { useEffect, type ReactNode } from "react";
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
2
  import { Clock, Loader2, Ticket, XCircle } from "lucide-react";
3
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
4
  import { readableTextOn } from "../theme/contrast";
@@ -40,7 +40,31 @@ export function BundleConfirmation({
40
40
  }: BundleConfirmationProps) {
41
41
  const theme = useForgeTheme();
42
42
  const t = useForgeT();
43
- const { clearCart } = useCart();
43
+ const { clearCart, ticketItems } = useCart();
44
+ /**
45
+ * The reserved seats this bundle bought, by name, read off the cart lines
46
+ * BEFORE the cart is emptied below. The finalize answer names the child
47
+ * orders and nothing in them, and the cart is the one place the labels the
48
+ * buyer chose on the map still exist on this page.
49
+ */
50
+ const seatLines = useMemo(
51
+ () =>
52
+ ticketItems.flatMap((item) =>
53
+ Object.entries(item.ticketMeta)
54
+ .filter(([ticketId, meta]) => (meta.seats?.length ?? 0) > 0 && (item.tickets[ticketId] ?? 0) > 0)
55
+ .map(([ticketId, meta]) => ({
56
+ key: `${item.eventId}-${ticketId}`,
57
+ eventTitle: item.eventTitle,
58
+ title: meta.title,
59
+ labels: (meta.seats ?? []).map((seat) => seat.label),
60
+ })),
61
+ ),
62
+ [ticketItems],
63
+ );
64
+ const [seatsBought, setSeatsBought] = useState<typeof seatLines>([]);
65
+ useEffect(() => {
66
+ if (seatLines.length > 0) setSeatsBought(seatLines);
67
+ }, [seatLines]);
44
68
  // A guest has no account screen behind "View your orders": the link would
45
69
  // land them on a login wall for an order their EMAIL is the receipt for.
46
70
  const { user } = usePublicAuth();
@@ -190,6 +214,27 @@ export function BundleConfirmation({
190
214
  </div>
191
215
  )}
192
216
 
217
+ {seatsBought.length > 0 && (
218
+ <div
219
+ data-testid="confirmation-seats"
220
+ style={{ textAlign: "left", margin: "0 auto 20px", maxWidth: 360, fontSize: 14 }}
221
+ >
222
+ <div style={{ fontWeight: 600, marginBottom: 4 }}>{t("forge.bundle_confirmation.seats_title")}</div>
223
+ {seatsBought.map((line) => (
224
+ <div key={line.key} style={{ marginBottom: 6 }}>
225
+ <div style={{ opacity: 0.75 }}>
226
+ {line.eventTitle} · {line.title}
227
+ </div>
228
+ {line.labels.map((label) => (
229
+ <div key={label} style={{ paddingLeft: 12 }}>
230
+ {label}
231
+ </div>
232
+ ))}
233
+ </div>
234
+ ))}
235
+ </div>
236
+ )}
237
+
193
238
  <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
194
239
  {user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
195
240
  {link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
@@ -8,6 +8,7 @@ import { readableTextOn } from "../theme/contrast";
8
8
  import { useAmountFormatter } from "../format/useFormatCurrency";
9
9
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
10
10
  import { CartLineOptions } from "./CartLineOptions";
11
+ import { internalPath } from "../../runtime/pages";
11
12
 
12
13
  export interface CartProps {
13
14
  /** Custom cart icon (defaults to a shopping bag). */
@@ -122,114 +123,178 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
122
123
  )}
123
124
  </button>
124
125
 
125
- {isCartOpen && typeof document !== "undefined" && createPortal(
126
- <>
127
- <style>{KEYFRAMES}</style>
128
- <div
129
- onClick={() => setCartOpen(false)}
130
- aria-label={translate("forge.cart.aria_close")}
131
- style={{ position: "fixed", inset: 0, zIndex: OVERLAY_Z, background: "rgba(0,0,0,0.3)" }}
132
- />
133
- <aside
134
- style={{
135
- position: "fixed",
136
- top: 0,
137
- right: 0,
138
- height: "100%",
139
- width: "100%",
140
- maxWidth: 420,
141
- zIndex: DRAWER_Z,
142
- background: t.background,
143
- color: t.text,
144
- borderLeft: `2px solid ${t.primary}`,
145
- boxShadow: "-8px 0 30px -20px rgba(0,0,0,0.5)",
146
- display: "flex",
147
- flexDirection: "column",
148
- fontFamily: t.fontFamily,
149
- animation: "forge-cart-slide .3s cubic-bezier(.4,0,.2,1)",
150
- }}
151
- >
126
+ {isCartOpen &&
127
+ typeof document !== "undefined" &&
128
+ createPortal(
129
+ <>
130
+ <style>{KEYFRAMES}</style>
152
131
  <div
132
+ onClick={() => setCartOpen(false)}
133
+ aria-label={translate("forge.cart.aria_close")}
134
+ style={{ position: "fixed", inset: 0, zIndex: OVERLAY_Z, background: "rgba(0,0,0,0.3)" }}
135
+ />
136
+ <aside
153
137
  style={{
138
+ position: "fixed",
139
+ top: 0,
140
+ right: 0,
141
+ height: "100%",
142
+ width: "100%",
143
+ maxWidth: 420,
144
+ zIndex: DRAWER_Z,
145
+ background: t.background,
146
+ color: t.text,
147
+ borderLeft: `2px solid ${t.primary}`,
148
+ boxShadow: "-8px 0 30px -20px rgba(0,0,0,0.5)",
154
149
  display: "flex",
155
- alignItems: "center",
156
- justifyContent: "space-between",
157
- padding: 16,
158
- borderBottom: `1px solid ${t.border}`,
150
+ flexDirection: "column",
151
+ fontFamily: t.fontFamily,
152
+ animation: "forge-cart-slide .3s cubic-bezier(.4,0,.2,1)",
159
153
  }}
160
154
  >
161
- <h2 style={{ fontSize: 18, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>
162
- {translate("forge.cart.title")}
163
- </h2>
164
- <button
165
- type="button"
166
- onClick={() => setCartOpen(false)}
167
- aria-label={translate("forge.cart.aria_close")}
168
- style={{ background: "transparent", border: "none", cursor: "pointer", color: t.text }}
155
+ <div
156
+ style={{
157
+ display: "flex",
158
+ alignItems: "center",
159
+ justifyContent: "space-between",
160
+ padding: 16,
161
+ borderBottom: `1px solid ${t.border}`,
162
+ }}
169
163
  >
170
- <X size={22} />
171
- </button>
172
- </div>
164
+ <h2 style={{ fontSize: 18, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>
165
+ {translate("forge.cart.title")}
166
+ </h2>
167
+ <button
168
+ type="button"
169
+ onClick={() => setCartOpen(false)}
170
+ aria-label={translate("forge.cart.aria_close")}
171
+ style={{ background: "transparent", border: "none", cursor: "pointer", color: t.text }}
172
+ >
173
+ <X size={22} />
174
+ </button>
175
+ </div>
173
176
 
174
- <div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 16 }}>
175
- {count === 0 && (
176
- <p style={{ textAlign: "center", opacity: 0.7 }}>{translate("forge.cart.empty_title")}</p>
177
- )}
177
+ <div
178
+ style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 16 }}
179
+ >
180
+ {count === 0 && (
181
+ <p style={{ textAlign: "center", opacity: 0.7 }}>{translate("forge.cart.empty_title")}</p>
182
+ )}
178
183
 
179
- {ticketItems.map((ticket) => (
180
- <div
181
- key={ticket.eventId}
182
- data-testid="cart-ticket-line"
183
- style={{
184
- display: "flex",
185
- gap: 12,
186
- alignItems: "flex-start",
187
- borderBottom: `1px solid ${t.border}`,
188
- paddingBottom: 16,
189
- position: "relative",
190
- }}
191
- >
192
- <img
193
- src={ticket.coverImage || undefined}
194
- alt={ticket.eventTitle}
184
+ {ticketItems.map((ticket) => (
185
+ <div
186
+ key={ticket.eventId}
187
+ data-testid="cart-ticket-line"
195
188
  style={{
196
- width: 64,
197
- height: 64,
198
- objectFit: "cover",
199
- borderRadius: t.cornerRadius,
200
- background: `${t.primary}10`,
201
- flexShrink: 0,
189
+ display: "flex",
190
+ gap: 12,
191
+ alignItems: "flex-start",
192
+ borderBottom: `1px solid ${t.border}`,
193
+ paddingBottom: 16,
194
+ position: "relative",
202
195
  }}
203
- />
204
- <div style={{ flex: 1, minWidth: 0, paddingRight: 28 }}>
205
- <div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
206
- {ticket.eventTitle}
207
- </div>
208
- {Object.entries(ticket.tickets).map(([id, qty]) => (
209
- <div key={id} style={{ fontSize: 12, marginTop: 4, opacity: 0.75 }}>
210
- {translate("forge.cart.ticket_line", {
211
- ticket_title: ticket.ticketMeta[id]?.title ?? translate("forge.cart.ticket_fallback_title"),
212
- quantity: qty,
213
- })}
196
+ >
197
+ <img
198
+ src={ticket.coverImage || undefined}
199
+ alt={ticket.eventTitle}
200
+ style={{
201
+ width: 64,
202
+ height: 64,
203
+ objectFit: "cover",
204
+ borderRadius: t.cornerRadius,
205
+ background: `${t.primary}10`,
206
+ flexShrink: 0,
207
+ }}
208
+ />
209
+ <div style={{ flex: 1, minWidth: 0, paddingRight: 28 }}>
210
+ <div
211
+ style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
212
+ >
213
+ {ticket.eventTitle}
214
214
  </div>
215
- ))}
216
- <div style={{ fontSize: 14, marginTop: 4, opacity: 0.8 }}>{fmt(ticketTotal(ticket))}</div>
215
+ {Object.entries(ticket.tickets).map(([id, qty]) => {
216
+ // A reserved-seating tier: its quantity is its seat
217
+ // count, so the seats are listed by name and there is
218
+ // no stepper. Changing them happens on the event page,
219
+ // where the map is.
220
+ const seats = ticket.ticketMeta[id]?.seats ?? [];
221
+ return (
222
+ <div key={id}>
223
+ <div style={{ fontSize: 12, marginTop: 4, opacity: 0.75 }}>
224
+ {translate("forge.cart.ticket_line", {
225
+ ticket_title:
226
+ ticket.ticketMeta[id]?.title ?? translate("forge.cart.ticket_fallback_title"),
227
+ quantity: qty,
228
+ })}
229
+ </div>
230
+ {seats.length > 0 && (
231
+ <div
232
+ data-testid="cart-seat-chips"
233
+ style={{ display: "flex", flexWrap: "wrap", gap: 4, marginTop: 4 }}
234
+ >
235
+ {seats.map((seat) => (
236
+ <span
237
+ key={seat.id}
238
+ data-testid="cart-seat-chip"
239
+ style={{
240
+ padding: "2px 8px",
241
+ borderRadius: 999,
242
+ background: `${t.primary}14`,
243
+ color: t.text,
244
+ fontSize: 11,
245
+ lineHeight: 1.4,
246
+ }}
247
+ >
248
+ {seat.label}
249
+ </span>
250
+ ))}
251
+ </div>
252
+ )}
253
+ </div>
254
+ );
255
+ })}
256
+ {Object.values(ticket.ticketMeta).some((meta) => (meta.seats?.length ?? 0) > 0) && (
257
+ <a
258
+ href={internalPath("events/$slug", { slug: ticket.eventSlug || ticket.eventId })}
259
+ data-testid="cart-change-seats"
260
+ onClick={() => setCartOpen(false)}
261
+ style={{
262
+ display: "inline-block",
263
+ fontSize: 12,
264
+ marginTop: 6,
265
+ color: t.primary,
266
+ textDecoration: "underline",
267
+ }}
268
+ >
269
+ {translate("forge.cart.change_seats")}
270
+ </a>
271
+ )}
272
+ <div style={{ fontSize: 14, marginTop: 4, opacity: 0.8 }}>{fmt(ticketTotal(ticket))}</div>
273
+ </div>
274
+ <button
275
+ type="button"
276
+ // Removing the ticket takes its add-ons with it — they
277
+ // cannot be bought on their own, and leaving them would
278
+ // produce a checkout the server refuses.
279
+ onClick={() => removeTickets(ticket.eventId)}
280
+ aria-label={translate("forge.cart.aria_remove_tickets")}
281
+ style={{
282
+ position: "absolute",
283
+ top: 0,
284
+ right: 0,
285
+ background: "transparent",
286
+ border: "none",
287
+ cursor: "pointer",
288
+ color: t.primary,
289
+ padding: 4,
290
+ }}
291
+ >
292
+ <Trash2 size={16} />
293
+ </button>
217
294
  </div>
218
- <button
219
- type="button"
220
- // Removing the ticket takes its add-ons with it — they
221
- // cannot be bought on their own, and leaving them would
222
- // produce a checkout the server refuses.
223
- onClick={() => removeTickets(ticket.eventId)}
224
- aria-label={translate("forge.cart.aria_remove_tickets")}
225
- style={{ position: "absolute", top: 0, right: 0, background: "transparent", border: "none", cursor: "pointer", color: t.primary, padding: 4 }}
226
- >
227
- <Trash2 size={16} />
228
- </button>
229
- </div>
230
- ))}
295
+ ))}
231
296
 
232
- {cartItems.map((item) => (
297
+ {cartItems.map((item) => (
233
298
  <div
234
299
  key={item.productId + item.productVariantId + String(item.isGift) + (item.recipientEmail || "")}
235
300
  // Named so a test can assert on the LINE rather than the
@@ -300,55 +365,66 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
300
365
  type="button"
301
366
  onClick={() => removeFromCart(item.productVariantId, item.isGift, item.recipientEmail)}
302
367
  aria-label={translate("forge.cart.aria_remove_item")}
303
- style={{ position: "absolute", top: 0, right: 0, background: "transparent", border: "none", cursor: "pointer", color: t.primary, padding: 4 }}
368
+ style={{
369
+ position: "absolute",
370
+ top: 0,
371
+ right: 0,
372
+ background: "transparent",
373
+ border: "none",
374
+ cursor: "pointer",
375
+ color: t.primary,
376
+ padding: 4,
377
+ }}
304
378
  >
305
379
  <Trash2 size={16} />
306
380
  </button>
307
381
  </div>
308
382
  ))}
309
- </div>
383
+ </div>
310
384
 
311
- {count > 0 && (
312
- <div
313
- style={{
314
- padding: 16,
315
- borderTop: `1px solid ${t.border}`,
316
- background: t.background,
317
- }}
318
- >
319
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
320
- <span style={{ fontSize: 18, fontWeight: 600 }}>{translate("forge.cart.total")}</span>
321
- <span style={{ fontSize: 18, fontWeight: 800, color: t.primary, textAlign: "right" }}>
322
- {fmt(total)}
323
- {pricesIncludeTax && (
324
- <span style={{ display: "block", fontSize: 11, fontWeight: 400, color: t.text, opacity: 0.65 }}>
325
- {translate("forge.cart.incl_tax")}
326
- </span>
327
- )}
328
- </span>
329
- </div>
330
- <button
331
- type="button"
332
- onClick={goCheckout}
385
+ {count > 0 && (
386
+ <div
333
387
  style={{
334
- width: "100%",
335
- padding: "12px 16px",
336
- borderRadius: t.cornerRadius,
337
- border: "none",
338
- fontWeight: 700,
339
- cursor: "pointer",
340
- background: t.primary,
341
- color: onPrimary,
388
+ padding: 16,
389
+ borderTop: `1px solid ${t.border}`,
390
+ background: t.background,
342
391
  }}
343
392
  >
344
- {translate("forge.cart.checkout")}
345
- </button>
346
- </div>
347
- )}
348
- </aside>
349
- </>,
350
- document.body,
351
- )}
393
+ <div
394
+ style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}
395
+ >
396
+ <span style={{ fontSize: 18, fontWeight: 600 }}>{translate("forge.cart.total")}</span>
397
+ <span style={{ fontSize: 18, fontWeight: 800, color: t.primary, textAlign: "right" }}>
398
+ {fmt(total)}
399
+ {pricesIncludeTax && (
400
+ <span style={{ display: "block", fontSize: 11, fontWeight: 400, color: t.text, opacity: 0.65 }}>
401
+ {translate("forge.cart.incl_tax")}
402
+ </span>
403
+ )}
404
+ </span>
405
+ </div>
406
+ <button
407
+ type="button"
408
+ onClick={goCheckout}
409
+ style={{
410
+ width: "100%",
411
+ padding: "12px 16px",
412
+ borderRadius: t.cornerRadius,
413
+ border: "none",
414
+ fontWeight: 700,
415
+ cursor: "pointer",
416
+ background: t.primary,
417
+ color: onPrimary,
418
+ }}
419
+ >
420
+ {translate("forge.cart.checkout")}
421
+ </button>
422
+ </div>
423
+ )}
424
+ </aside>
425
+ </>,
426
+ document.body,
427
+ )}
352
428
  </>
353
429
  );
354
430
  }