@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
@@ -65,6 +65,9 @@
65
65
  .fg\:inset-0 {
66
66
  inset: calc(var(--fg-spacing) * 0);
67
67
  }
68
+ .fg\:top-2 {
69
+ top: calc(var(--fg-spacing) * 2);
70
+ }
68
71
  .fg\:top-4 {
69
72
  top: calc(var(--fg-spacing) * 4);
70
73
  }
@@ -74,18 +77,30 @@
74
77
  .fg\:right-0 {
75
78
  right: calc(var(--fg-spacing) * 0);
76
79
  }
80
+ .fg\:right-2 {
81
+ right: calc(var(--fg-spacing) * 2);
82
+ }
83
+ .fg\:right-14 {
84
+ right: calc(var(--fg-spacing) * 14);
85
+ }
77
86
  .fg\:bottom-0 {
78
87
  bottom: calc(var(--fg-spacing) * 0);
79
88
  }
80
89
  .fg\:left-0 {
81
90
  left: calc(var(--fg-spacing) * 0);
82
91
  }
92
+ .fg\:left-2 {
93
+ left: calc(var(--fg-spacing) * 2);
94
+ }
83
95
  .fg\:z-40 {
84
96
  z-index: 40;
85
97
  }
86
98
  .fg\:z-50 {
87
99
  z-index: 50;
88
100
  }
101
+ .fg\:m-0 {
102
+ margin: calc(var(--fg-spacing) * 0);
103
+ }
89
104
  .fg\:mx-auto {
90
105
  margin-inline: auto;
91
106
  }
@@ -158,33 +173,54 @@
158
173
  .fg\:hidden {
159
174
  display: none;
160
175
  }
176
+ .fg\:inline-block {
177
+ display: inline-block;
178
+ }
161
179
  .fg\:inline-flex {
162
180
  display: inline-flex;
163
181
  }
164
182
  .fg\:h-0\.5 {
165
183
  height: calc(var(--fg-spacing) * 0.5);
166
184
  }
185
+ .fg\:h-3 {
186
+ height: calc(var(--fg-spacing) * 3);
187
+ }
167
188
  .fg\:h-4 {
168
189
  height: calc(var(--fg-spacing) * 4);
169
190
  }
170
191
  .fg\:h-8 {
171
192
  height: calc(var(--fg-spacing) * 8);
172
193
  }
194
+ .fg\:h-9 {
195
+ height: calc(var(--fg-spacing) * 9);
196
+ }
173
197
  .fg\:h-12 {
174
198
  height: calc(var(--fg-spacing) * 12);
175
199
  }
176
200
  .fg\:h-\[70vh\] {
177
201
  height: 70vh;
178
202
  }
203
+ .fg\:h-\[320px\] {
204
+ height: 320px;
205
+ }
179
206
  .fg\:h-full {
180
207
  height: 100%;
181
208
  }
209
+ .fg\:min-h-\[320px\] {
210
+ min-height: 320px;
211
+ }
212
+ .fg\:w-3 {
213
+ width: calc(var(--fg-spacing) * 3);
214
+ }
182
215
  .fg\:w-4 {
183
216
  width: calc(var(--fg-spacing) * 4);
184
217
  }
185
218
  .fg\:w-8 {
186
219
  width: calc(var(--fg-spacing) * 8);
187
220
  }
221
+ .fg\:w-9 {
222
+ width: calc(var(--fg-spacing) * 9);
223
+ }
188
224
  .fg\:w-10 {
189
225
  width: calc(var(--fg-spacing) * 10);
190
226
  }
@@ -224,9 +260,15 @@
224
260
  .fg\:cursor-pointer {
225
261
  cursor: pointer;
226
262
  }
263
+ .fg\:touch-none {
264
+ touch-action: none;
265
+ }
227
266
  .fg\:resize-y {
228
267
  resize: vertical;
229
268
  }
269
+ .fg\:list-none {
270
+ list-style-type: none;
271
+ }
230
272
  .fg\:grid-cols-1 {
231
273
  grid-template-columns: repeat(1, minmax(0, 1fr));
232
274
  }
@@ -236,6 +278,9 @@
236
278
  .fg\:flex-col {
237
279
  flex-direction: column;
238
280
  }
281
+ .fg\:flex-row {
282
+ flex-direction: row;
283
+ }
239
284
  .fg\:flex-wrap {
240
285
  flex-wrap: wrap;
241
286
  }
@@ -308,6 +353,9 @@
308
353
  margin-block-end: calc(calc(var(--fg-spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
309
354
  }
310
355
  }
356
+ .fg\:gap-x-4 {
357
+ column-gap: calc(var(--fg-spacing) * 4);
358
+ }
311
359
  .fg\:space-x-4 {
312
360
  :where(& > :not(:last-child)) {
313
361
  --tw-space-x-reverse: 0;
@@ -315,6 +363,9 @@
315
363
  margin-inline-end: calc(calc(var(--fg-spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
316
364
  }
317
365
  }
366
+ .fg\:gap-y-2 {
367
+ row-gap: calc(var(--fg-spacing) * 2);
368
+ }
318
369
  .fg\:overflow-hidden {
319
370
  overflow: hidden;
320
371
  }
@@ -340,6 +391,10 @@
340
391
  border-style: var(--tw-border-style);
341
392
  border-width: 1px;
342
393
  }
394
+ .fg\:border-0 {
395
+ border-style: var(--tw-border-style);
396
+ border-width: 0px;
397
+ }
343
398
  .fg\:border-2 {
344
399
  border-style: var(--tw-border-style);
345
400
  border-width: 2px;
@@ -387,6 +442,9 @@
387
442
  .fg\:bg-transparent {
388
443
  background-color: transparent;
389
444
  }
445
+ .fg\:p-0 {
446
+ padding: calc(var(--fg-spacing) * 0);
447
+ }
390
448
  .fg\:p-0\.5 {
391
449
  padding: calc(var(--fg-spacing) * 0.5);
392
450
  }
@@ -583,6 +641,10 @@
583
641
  --tw-outline-style: none;
584
642
  outline-style: none;
585
643
  }
644
+ .fg\:select-none {
645
+ -webkit-user-select: none;
646
+ user-select: none;
647
+ }
586
648
  .fg\:focus\:border-primary {
587
649
  &:focus {
588
650
  border-color: var(--fg-color-primary);
@@ -678,6 +740,11 @@
678
740
  height: calc(var(--fg-spacing) * 64);
679
741
  }
680
742
  }
743
+ .fg\:md\:h-\[440px\] {
744
+ @media (width >= 48rem) {
745
+ height: 440px;
746
+ }
747
+ }
681
748
  .fg\:md\:w-64 {
682
749
  @media (width >= 48rem) {
683
750
  width: calc(var(--fg-spacing) * 64);
@@ -0,0 +1,190 @@
1
+ import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
2
+ import { AlertCircle, X } from "lucide-react";
3
+ import type { IEvent } from "../../../types/models";
4
+ import type { EventSeat, EventSeatingState } from "../../headless/event/useEventSeating";
5
+ import type { ToggleSeatResult } from "../../headless/event/useEventCheckout";
6
+ import { useForgeT } from "../../../i18n";
7
+ import { useForgeTheme, type ForgeTheme } from "../../theme/ForgeThemeProvider";
8
+ import { CallHelpHint } from "../../media/CallHelpHint";
9
+ import { Loading } from "../Loading";
10
+ import { SeatMapCanvas } from "./SeatMapCanvas";
11
+ import { SeatLegend, type SeatLegendTier } from "./SeatLegend";
12
+ import { tierColorMap } from "./paint";
13
+
14
+ export interface EventSeatPickerProps {
15
+ /** The event whose tiers price the seats. Tier order fixes the palette. */
16
+ event: IEvent;
17
+ /** The joined seating read, from `useEventCheckout().seating` or `useEventSeating`. */
18
+ seating: EventSeatingState;
19
+ /** ticketId -> the EVENT seat ids the buyer holds. */
20
+ selectedSeats: Record<string, string[]>;
21
+ /**
22
+ * A tap on a seat. `useEventCheckout().toggleSeat` fits directly; a host
23
+ * driving its own state may return nothing, in which case no notice is shown.
24
+ */
25
+ onToggleSeat: (ticketId: string, seatId: string) => ToggleSeatResult | void;
26
+ /** Seats that left the selection because somebody else got them. Rendered as an alert. */
27
+ seatsTaken?: { labels: string[] } | null;
28
+ onDismissSeatsTaken?: () => void;
29
+ /** Format a numeric amount for display. */
30
+ formatAmount: (amount: number) => string;
31
+ /** Host classes on the outer element, passed through untouched. */
32
+ className?: string;
33
+ }
34
+
35
+ /** Append an alpha to a 6-digit hex color. */
36
+ const a = (hex: string, alpha: number) =>
37
+ hex +
38
+ Math.round(alpha * 255)
39
+ .toString(16)
40
+ .padStart(2, "0");
41
+
42
+ const NOTICE_MS = 2500;
43
+
44
+ /**
45
+ * The buyer's seat picker: the map, the legend, and the two notices a buyer
46
+ * must see without asking (seats taken from under them, a tier at its cap).
47
+ *
48
+ * Rendered inside `EventTickets`' ticket step when `event.hasSeatMap`, and
49
+ * exported on its own for a host that composes the checkout from the headless
50
+ * hook. Colours come from the site theme except the tier palette, which the
51
+ * map, the legend and the tier rows share through `tierColorMap`.
52
+ */
53
+ export function EventSeatPicker({
54
+ event,
55
+ seating,
56
+ selectedSeats,
57
+ onToggleSeat,
58
+ seatsTaken,
59
+ onDismissSeatsTaken,
60
+ formatAmount,
61
+ className,
62
+ }: EventSeatPickerProps) {
63
+ const t = useForgeT();
64
+ const theme = useForgeTheme();
65
+ const [notice, setNotice] = useState<string | null>(null);
66
+
67
+ // A brief inline notice, cleared on its own: it answers "why did nothing
68
+ // happen?" for one tap and must not sit on the map for ever.
69
+ useEffect(() => {
70
+ if (!notice) return;
71
+ const id = setTimeout(() => setNotice(null), NOTICE_MS);
72
+ return () => clearTimeout(id);
73
+ }, [notice]);
74
+
75
+ const tierColors = useMemo(() => tierColorMap(event.tickets.map((ticket) => ticket.id)), [event.tickets]);
76
+
77
+ const selectedIds = useMemo(() => new Set(Object.values(selectedSeats).flat()), [selectedSeats]);
78
+
79
+ const legendTiers = useMemo<SeatLegendTier[]>(
80
+ () =>
81
+ event.tickets
82
+ .filter((ticket) => seating.seatedTierIds.has(ticket.id))
83
+ .map((ticket) => ({
84
+ id: ticket.id,
85
+ title: ticket.title,
86
+ price: Number(ticket.price),
87
+ color: tierColors[ticket.id]!,
88
+ left: seating.availableByTier[ticket.id] ?? 0,
89
+ })),
90
+ [event.tickets, seating.seatedTierIds, seating.availableByTier, tierColors],
91
+ );
92
+
93
+ const onSeatTap = useCallback(
94
+ (seat: EventSeat) => {
95
+ const result = onToggleSeat(seat.eventTicketId, seat.id);
96
+ if (!result || result.ok) return;
97
+ const tier = event.tickets.find((ticket) => ticket.id === seat.eventTicketId);
98
+ if (result.reason === "max") setNotice(t("forge.event_seat_picker.max_reached", { tier: tier?.title ?? "" }));
99
+ else if (result.reason === "unavailable") setNotice(t("forge.event_seat_picker.seat_unavailable"));
100
+ else if (result.reason === "mix") setNotice(t("forge.event_seat_picker.mix_blocked"));
101
+ },
102
+ [onToggleSeat, event.tickets, t],
103
+ );
104
+
105
+ const layout = seating.seating?.layout;
106
+
107
+ return (
108
+ <div data-testid="event-seat-picker" className={className ?? "fg:flex fg:flex-col fg:gap-3"}>
109
+ <div className="fg:flex fg:items-center fg:gap-2">
110
+ <h3 style={{ fontSize: 16, fontWeight: 700, margin: 0 }}>{t("forge.event_seat_picker.title")}</h3>
111
+ <CallHelpHint label={t("forge.event_seat_picker.help_label")}>{t("forge.event_seat_picker.help_body")}</CallHelpHint>
112
+ </div>
113
+
114
+ {/* State the buyer has to act on, so it is a visible alert and not a
115
+ `?`: their selection just shrank, and these are the seats that went. */}
116
+ {seatsTaken && seatsTaken.labels.length > 0 && (
117
+ <div role="alert" data-testid="seats-taken-alert" style={alertStyle("#dc2626", theme)}>
118
+ <AlertCircle size={16} style={{ flexShrink: 0, marginTop: 2 }} />
119
+ <div style={{ flex: 1 }}>
120
+ <div style={{ fontWeight: 700 }}>{t("forge.event_seat_picker.seats_taken_title")}</div>
121
+ <div style={{ fontWeight: 400, marginTop: 2 }}>
122
+ {t("forge.event_seat_picker.seats_taken_body", { seats: seatsTaken.labels.join(", ") })}
123
+ </div>
124
+ </div>
125
+ {onDismissSeatsTaken && (
126
+ <button
127
+ type="button"
128
+ aria-label={t("forge.event_seat_picker.dismiss")}
129
+ onClick={onDismissSeatsTaken}
130
+ className="fg:cursor-pointer fg:p-0 fg:bg-transparent fg:border-0"
131
+ style={{ color: "inherit" }}
132
+ >
133
+ <X size={16} />
134
+ </button>
135
+ )}
136
+ </div>
137
+ )}
138
+
139
+ {seating.isLoading && !layout && (
140
+ <div
141
+ data-testid="seat-picker-loading"
142
+ className="fg:flex fg:items-center fg:justify-center fg:min-h-[320px]"
143
+ style={{ border: `1px solid ${a(theme.colors.primary, 0.25)}`, borderRadius: theme.cornerRadius }}
144
+ >
145
+ <Loading label={t("forge.event_seat_picker.loading")} />
146
+ </div>
147
+ )}
148
+
149
+ {!seating.isLoading && !layout && (
150
+ <p data-testid="seat-picker-empty" style={{ opacity: 0.7, fontSize: 14, margin: 0 }}>
151
+ {t("forge.event_seat_picker.empty")}
152
+ </p>
153
+ )}
154
+
155
+ {layout && (
156
+ <>
157
+ <SeatMapCanvas
158
+ layout={layout}
159
+ bySource={seating.bySource}
160
+ tierColors={tierColors}
161
+ selectedIds={selectedIds}
162
+ onSeatTap={onSeatTap}
163
+ overlay={
164
+ notice ? (
165
+ <div role="status" data-testid="seat-picker-notice" style={{ ...alertStyle(theme.colors.primary, theme), marginTop: 0 }}>
166
+ <AlertCircle size={14} />
167
+ {notice}
168
+ </div>
169
+ ) : null
170
+ }
171
+ />
172
+ <SeatLegend tiers={legendTiers} formatAmount={formatAmount} />
173
+ </>
174
+ )}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ const alertStyle = (color: string, t: ForgeTheme): CSSProperties => ({
180
+ padding: "8px 12px",
181
+ borderRadius: t.cornerRadius,
182
+ fontSize: 14,
183
+ fontWeight: 600,
184
+ background: a(color, 0.1),
185
+ color,
186
+ border: `1px solid ${a(color, 0.3)}`,
187
+ display: "flex",
188
+ alignItems: "flex-start",
189
+ gap: 8,
190
+ });
@@ -0,0 +1,73 @@
1
+ import { useForgeT } from "../../../i18n";
2
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
3
+ import { UNAVAILABLE_FILL } from "./paint";
4
+
5
+ export interface SeatLegendTier {
6
+ id: string;
7
+ title: string;
8
+ /** Per-unit price, in the artist's currency. */
9
+ price: number;
10
+ /** The tier's colour on the map. */
11
+ color: string;
12
+ /** Seats still available on this tier. */
13
+ left: number;
14
+ }
15
+
16
+ export interface SeatLegendProps {
17
+ tiers: SeatLegendTier[];
18
+ formatAmount: (amount: number) => string;
19
+ /** Host classes on the outer element, passed through untouched. */
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * What the colours on the map mean: one chip per seated tier (colour, name,
25
+ * price, how many are left), plus the two states every seat can be in
26
+ * regardless of tier. The colours come from the same `tierColorMap` the
27
+ * canvas and the tier rows use, so this is the key to the map by construction.
28
+ */
29
+ export function SeatLegend({ tiers, formatAmount, className }: SeatLegendProps) {
30
+ const t = useForgeT();
31
+ const theme = useForgeTheme();
32
+ return (
33
+ <ul
34
+ data-testid="seat-legend"
35
+ aria-label={t("forge.seat_legend.aria_label")}
36
+ className={className ?? "fg:flex fg:flex-wrap fg:gap-x-4 fg:gap-y-2 fg:list-none fg:m-0 fg:p-0"}
37
+ style={{ fontSize: 13, color: theme.colors.text }}
38
+ >
39
+ {tiers.map((tier) => (
40
+ <li key={tier.id} data-testid="seat-legend-tier" className="fg:flex fg:items-center fg:gap-2">
41
+ <Swatch color={tier.color} />
42
+ <span style={{ fontWeight: 600 }}>{tier.title}</span>
43
+ <span style={{ opacity: 0.8 }}>{formatAmount(tier.price)}</span>
44
+ <span style={{ opacity: 0.65 }}>
45
+ {tier.left === 0
46
+ ? t("forge.seat_legend.sold_out")
47
+ : tier.left === 1
48
+ ? t("forge.seat_legend.left_one", { count: tier.left })
49
+ : t("forge.seat_legend.left_other", { count: tier.left })}
50
+ </span>
51
+ </li>
52
+ ))}
53
+ <li className="fg:flex fg:items-center fg:gap-2">
54
+ <Swatch color={theme.colors.primary} ring={theme.colors.primary} />
55
+ <span>{t("forge.seat_legend.selected")}</span>
56
+ </li>
57
+ <li className="fg:flex fg:items-center fg:gap-2">
58
+ <Swatch color={UNAVAILABLE_FILL} />
59
+ <span>{t("forge.seat_legend.unavailable")}</span>
60
+ </li>
61
+ </ul>
62
+ );
63
+ }
64
+
65
+ function Swatch({ color, ring }: { color: string; ring?: string }) {
66
+ return (
67
+ <span
68
+ aria-hidden
69
+ className="fg:inline-block fg:w-3 fg:h-3 fg:rounded-full fg:shrink-0"
70
+ style={{ background: color, boxShadow: ring ? `0 0 0 2px #ffffff, 0 0 0 3.5px ${ring}` : undefined }}
71
+ />
72
+ );
73
+ }