@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.
- package/package.json +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useEvents.ts +68 -0
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/i18n/de.json +79 -0
- package/src/i18n/en.json +79 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +10 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +25 -1
- package/src/ui/index.ts +7 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +5 -0
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- 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
|
+
}
|