@tribe-nest/forge 3.57.0 → 3.58.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
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import {
|
|
2
|
+
memo,
|
|
3
|
+
useCallback,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type SVGProps,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { Maximize2, Minus, Plus } from "lucide-react";
|
|
12
|
+
import {
|
|
13
|
+
boundingBoxOfPoints,
|
|
14
|
+
documentBoundingBox,
|
|
15
|
+
rectCenter,
|
|
16
|
+
SEAT_RADIUS,
|
|
17
|
+
sectionOutline,
|
|
18
|
+
shapeBoundingBox,
|
|
19
|
+
type Decor,
|
|
20
|
+
type GaArea,
|
|
21
|
+
type Rect,
|
|
22
|
+
type Seat,
|
|
23
|
+
type SeatMapDocument,
|
|
24
|
+
type Section,
|
|
25
|
+
type Shape,
|
|
26
|
+
type Table,
|
|
27
|
+
type TextObject,
|
|
28
|
+
} from "@tribe-nest/seating-core";
|
|
29
|
+
import type { EventSeat } from "../../headless/event/useEventSeating";
|
|
30
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
31
|
+
import { useForgeT } from "../../../i18n";
|
|
32
|
+
import {
|
|
33
|
+
DECOR_DEFAULTS,
|
|
34
|
+
LABEL_COLOR,
|
|
35
|
+
MIN_SEAT_SCREEN_PX,
|
|
36
|
+
MIN_TAP_TARGET_PX,
|
|
37
|
+
MUTED_LABEL_COLOR,
|
|
38
|
+
ROW_LABEL_MIN_SCALE,
|
|
39
|
+
SEAT_LABEL_MIN_SCALE,
|
|
40
|
+
seatPaint,
|
|
41
|
+
STAGE_FILL,
|
|
42
|
+
} from "./paint";
|
|
43
|
+
import { useSeatMapViewport } from "./useSeatMapViewport";
|
|
44
|
+
|
|
45
|
+
export interface SeatMapCanvasProps {
|
|
46
|
+
/** The layout frozen at bind time. */
|
|
47
|
+
layout: SeatMapDocument;
|
|
48
|
+
/** Event seats by their id INSIDE the layout, which is what a `<circle>` carries. */
|
|
49
|
+
bySource: ReadonlyMap<string, EventSeat>;
|
|
50
|
+
/** Tier id -> colour. Seats of a tier with no entry draw as "not for sale". */
|
|
51
|
+
tierColors: Readonly<Record<string, string>>;
|
|
52
|
+
/** EVENT seat ids the buyer holds. */
|
|
53
|
+
selectedIds: ReadonlySet<string>;
|
|
54
|
+
/** A tap on an available (or already selected) seat. Never fired for an unavailable one. */
|
|
55
|
+
onSeatTap: (seat: EventSeat) => void;
|
|
56
|
+
/** Rendered over the map's top-left, for notices that belong on the canvas. */
|
|
57
|
+
overlay?: ReactNode;
|
|
58
|
+
/** Host classes on the outer element, passed through untouched. */
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Append an alpha to a 6-digit hex color. */
|
|
63
|
+
const a = (hex: string, alpha: number) =>
|
|
64
|
+
hex +
|
|
65
|
+
Math.round(alpha * 255)
|
|
66
|
+
.toString(16)
|
|
67
|
+
.padStart(2, "0");
|
|
68
|
+
|
|
69
|
+
const pointsAttr = (points: readonly { x: number; y: number }[]) =>
|
|
70
|
+
points.map((point) => `${point.x},${point.y}`).join(" ");
|
|
71
|
+
|
|
72
|
+
const SOURCE_ATTR = "data-seat-source";
|
|
73
|
+
|
|
74
|
+
/** Which layout seat, if any, a pointer event landed on. One `closest()`, not one handler per seat. */
|
|
75
|
+
const resolveSeatSource = (target: EventTarget | null): string | null => {
|
|
76
|
+
if (!(target instanceof Element)) return null;
|
|
77
|
+
const hit = target.closest<Element>(`[${SOURCE_ATTR}]`);
|
|
78
|
+
return hit?.getAttribute(SOURCE_ATTR) ?? null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The SVG of a seat map for the BUYER: the frozen layout with every seat
|
|
83
|
+
* coloured by the tier that prices it, unavailable seats grey, the buyer's
|
|
84
|
+
* own seats ringed. Pan, zoom and tap come from `useSeatMapViewport`; the
|
|
85
|
+
* canvas itself only draws and resolves which seat a tap landed on.
|
|
86
|
+
*
|
|
87
|
+
* Everything is drawn in WORLD units under one `<g transform>`, so zooming is
|
|
88
|
+
* a transform change and not a re-layout of a few thousand nodes. Strokes
|
|
89
|
+
* that must stay hairline use `vectorEffect="non-scaling-stroke"`.
|
|
90
|
+
*/
|
|
91
|
+
export function SeatMapCanvas({ layout, bySource, tierColors, selectedIds, onSeatTap, overlay, className }: SeatMapCanvasProps) {
|
|
92
|
+
const t = useForgeT();
|
|
93
|
+
const theme = useForgeTheme();
|
|
94
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
95
|
+
const [hoveredSource, setHoveredSource] = useState<string | null>(null);
|
|
96
|
+
|
|
97
|
+
const fitRect = useMemo<Rect>(() => {
|
|
98
|
+
const box = documentBoundingBox(layout) ?? { x: 0, y: 0, width: layout.canvas.width, height: layout.canvas.height };
|
|
99
|
+
return { x: box.x - 40, y: box.y - 40, width: box.width + 80, height: box.height + 80 };
|
|
100
|
+
}, [layout]);
|
|
101
|
+
|
|
102
|
+
const onTap = useCallback(
|
|
103
|
+
(target: EventTarget | null) => {
|
|
104
|
+
const source = resolveSeatSource(target);
|
|
105
|
+
if (!source) return;
|
|
106
|
+
const seat = bySource.get(source);
|
|
107
|
+
if (!seat) return;
|
|
108
|
+
// A grey seat is not a control. The picker says why elsewhere; the map
|
|
109
|
+
// simply does not react, the way a disabled button does not.
|
|
110
|
+
if (seat.status !== "available" && !selectedIds.has(seat.id)) return;
|
|
111
|
+
onSeatTap(seat);
|
|
112
|
+
},
|
|
113
|
+
[bySource, onSeatTap, selectedIds],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const view = useSeatMapViewport({ containerRef, fitRect, onTap });
|
|
117
|
+
const { viewport, size } = view;
|
|
118
|
+
|
|
119
|
+
const seatRadius = Math.max(SEAT_RADIUS, MIN_SEAT_SCREEN_PX / viewport.scale);
|
|
120
|
+
const tapRadius = Math.max(seatRadius, MIN_TAP_TARGET_PX / 2 / viewport.scale);
|
|
121
|
+
const showSeatLabels = viewport.scale >= SEAT_LABEL_MIN_SCALE;
|
|
122
|
+
const showRowLabels = viewport.scale >= ROW_LABEL_MIN_SCALE;
|
|
123
|
+
|
|
124
|
+
const onPointerMove = (event: ReactPointerEvent<SVGSVGElement>) => {
|
|
125
|
+
view.pointerHandlers.onPointerMove(event);
|
|
126
|
+
if (event.pointerType !== "mouse") return;
|
|
127
|
+
const source = resolveSeatSource(event.target);
|
|
128
|
+
const seat = source ? bySource.get(source) : undefined;
|
|
129
|
+
// Unavailable seats get no hover: nothing happens if you click them, so
|
|
130
|
+
// nothing should light up when you approach them.
|
|
131
|
+
setHoveredSource(seat && (seat.status === "available" || selectedIds.has(seat.id)) ? source : null);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const cursor = view.isPanning ? "grabbing" : hoveredSource ? "pointer" : "grab";
|
|
135
|
+
const bg = layout.canvas.background;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
ref={containerRef}
|
|
140
|
+
data-testid="seat-map-canvas"
|
|
141
|
+
className={className ?? "fg:relative fg:w-full fg:h-[320px] fg:md:h-[440px] fg:overflow-hidden fg:touch-none fg:select-none"}
|
|
142
|
+
style={{
|
|
143
|
+
border: `1px solid ${a(theme.colors.primary, 0.25)}`,
|
|
144
|
+
borderRadius: theme.cornerRadius,
|
|
145
|
+
background: theme.colors.background,
|
|
146
|
+
touchAction: "none",
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<svg
|
|
150
|
+
role="application"
|
|
151
|
+
aria-label={t("forge.seat_map_canvas.aria_label")}
|
|
152
|
+
className="fg:block fg:w-full fg:h-full fg:touch-none fg:select-none"
|
|
153
|
+
width={size.width}
|
|
154
|
+
height={size.height}
|
|
155
|
+
viewBox={`0 0 ${size.width} ${size.height}`}
|
|
156
|
+
style={{ cursor, touchAction: "none" }}
|
|
157
|
+
onPointerDown={view.pointerHandlers.onPointerDown}
|
|
158
|
+
onPointerMove={onPointerMove}
|
|
159
|
+
onPointerUp={view.pointerHandlers.onPointerUp}
|
|
160
|
+
onPointerCancel={view.pointerHandlers.onPointerCancel}
|
|
161
|
+
onPointerLeave={() => setHoveredSource(null)}
|
|
162
|
+
>
|
|
163
|
+
<g transform={`translate(${viewport.x} ${viewport.y}) scale(${viewport.scale})`}>
|
|
164
|
+
<rect
|
|
165
|
+
x={0}
|
|
166
|
+
y={0}
|
|
167
|
+
width={layout.canvas.width}
|
|
168
|
+
height={layout.canvas.height}
|
|
169
|
+
fill="#ffffff"
|
|
170
|
+
stroke="#e5e7eb"
|
|
171
|
+
vectorEffect="non-scaling-stroke"
|
|
172
|
+
/>
|
|
173
|
+
{bg ? (
|
|
174
|
+
<image
|
|
175
|
+
href={bg.url}
|
|
176
|
+
x={bg.x}
|
|
177
|
+
y={bg.y}
|
|
178
|
+
width={bg.width}
|
|
179
|
+
height={bg.height}
|
|
180
|
+
opacity={bg.opacity}
|
|
181
|
+
preserveAspectRatio="none"
|
|
182
|
+
pointerEvents="none"
|
|
183
|
+
/>
|
|
184
|
+
) : null}
|
|
185
|
+
{layout.objects.map((object) => {
|
|
186
|
+
switch (object.type) {
|
|
187
|
+
case "section":
|
|
188
|
+
return (
|
|
189
|
+
<SectionLayer
|
|
190
|
+
key={object.id}
|
|
191
|
+
section={object}
|
|
192
|
+
bySource={bySource}
|
|
193
|
+
tierColors={tierColors}
|
|
194
|
+
selectedIds={selectedIds}
|
|
195
|
+
hoveredSource={hoveredSource}
|
|
196
|
+
primary={theme.colors.primary}
|
|
197
|
+
seatRadius={seatRadius}
|
|
198
|
+
tapRadius={tapRadius}
|
|
199
|
+
showSeatLabels={showSeatLabels}
|
|
200
|
+
showRowLabels={showRowLabels}
|
|
201
|
+
t={t}
|
|
202
|
+
/>
|
|
203
|
+
);
|
|
204
|
+
case "table":
|
|
205
|
+
return (
|
|
206
|
+
<TableLayer
|
|
207
|
+
key={object.id}
|
|
208
|
+
table={object}
|
|
209
|
+
bySource={bySource}
|
|
210
|
+
tierColors={tierColors}
|
|
211
|
+
selectedIds={selectedIds}
|
|
212
|
+
hoveredSource={hoveredSource}
|
|
213
|
+
primary={theme.colors.primary}
|
|
214
|
+
seatRadius={seatRadius}
|
|
215
|
+
tapRadius={tapRadius}
|
|
216
|
+
showSeatLabels={showSeatLabels}
|
|
217
|
+
t={t}
|
|
218
|
+
/>
|
|
219
|
+
);
|
|
220
|
+
case "ga":
|
|
221
|
+
return <GaLayer key={object.id} ga={object} chip={t("forge.seat_map_canvas.general_admission")} />;
|
|
222
|
+
case "shape":
|
|
223
|
+
return <DecorLayer key={object.id} decor={object} />;
|
|
224
|
+
case "text":
|
|
225
|
+
return <TextLayer key={object.id} text={object} />;
|
|
226
|
+
}
|
|
227
|
+
})}
|
|
228
|
+
</g>
|
|
229
|
+
</svg>
|
|
230
|
+
{overlay ? <div className="fg:absolute fg:top-2 fg:left-2 fg:right-14 fg:pointer-events-none">{overlay}</div> : null}
|
|
231
|
+
<div className="fg:absolute fg:top-2 fg:right-2 fg:flex fg:flex-col fg:gap-1">
|
|
232
|
+
<ZoomButton label={t("forge.seat_map_canvas.zoom_in")} onClick={view.zoomIn} testId="seat-map-zoom-in">
|
|
233
|
+
<Plus size={16} />
|
|
234
|
+
</ZoomButton>
|
|
235
|
+
<ZoomButton label={t("forge.seat_map_canvas.zoom_out")} onClick={view.zoomOut} testId="seat-map-zoom-out">
|
|
236
|
+
<Minus size={16} />
|
|
237
|
+
</ZoomButton>
|
|
238
|
+
<ZoomButton label={t("forge.seat_map_canvas.fit")} onClick={view.fit} testId="seat-map-fit">
|
|
239
|
+
<Maximize2 size={16} />
|
|
240
|
+
</ZoomButton>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ZoomButton({ label, onClick, testId, children }: { label: string; onClick: () => void; testId: string; children: ReactNode }) {
|
|
247
|
+
const theme = useForgeTheme();
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
aria-label={label}
|
|
252
|
+
title={label}
|
|
253
|
+
data-testid={testId}
|
|
254
|
+
onClick={onClick}
|
|
255
|
+
className="fg:flex fg:items-center fg:justify-center fg:w-9 fg:h-9 fg:cursor-pointer"
|
|
256
|
+
style={{
|
|
257
|
+
background: theme.colors.background,
|
|
258
|
+
color: theme.colors.text,
|
|
259
|
+
border: `1px solid ${a(theme.colors.primary, 0.3)}`,
|
|
260
|
+
borderRadius: theme.cornerRadius,
|
|
261
|
+
boxShadow: "0 1px 2px rgba(0,0,0,0.08)",
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{children}
|
|
265
|
+
</button>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Layers ────────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
interface SeatLayerCommon {
|
|
272
|
+
bySource: ReadonlyMap<string, EventSeat>;
|
|
273
|
+
tierColors: Readonly<Record<string, string>>;
|
|
274
|
+
selectedIds: ReadonlySet<string>;
|
|
275
|
+
hoveredSource: string | null;
|
|
276
|
+
primary: string;
|
|
277
|
+
seatRadius: number;
|
|
278
|
+
tapRadius: number;
|
|
279
|
+
showSeatLabels: boolean;
|
|
280
|
+
t: (key: string, vars?: Record<string, string | number>) => string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const SectionLayer = memo(function SectionLayer({
|
|
284
|
+
section,
|
|
285
|
+
showRowLabels,
|
|
286
|
+
...common
|
|
287
|
+
}: SeatLayerCommon & { section: Section; showRowLabels: boolean }) {
|
|
288
|
+
const outline = sectionOutline(section);
|
|
289
|
+
const box = outline.length > 0 ? boundingBoxOfPoints(outline) : null;
|
|
290
|
+
const labelAt = section.labelPosition ?? (box ? rectCenter(box) : null);
|
|
291
|
+
const rowLabelSide = section.rowLabelSide ?? "left";
|
|
292
|
+
const labelFont = Math.max(10, common.seatRadius * 1.6);
|
|
293
|
+
const rowFont = Math.max(8, common.seatRadius * 1.4);
|
|
294
|
+
// The section takes its tint from its first mapped seat's tier, so the
|
|
295
|
+
// outline agrees with the seats inside it rather than with a category colour
|
|
296
|
+
// the buyer never sees.
|
|
297
|
+
const tint = sectionTint(section.rows.flatMap((row) => row.seats), common) ?? "#9ca3af";
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<g data-object-id={section.id} data-object-type="section" data-testid="seat-map-section">
|
|
301
|
+
{outline.length >= 3 ? (
|
|
302
|
+
<polygon
|
|
303
|
+
points={pointsAttr(outline)}
|
|
304
|
+
fill={tint}
|
|
305
|
+
fillOpacity={0.06}
|
|
306
|
+
stroke={tint}
|
|
307
|
+
strokeOpacity={0.35}
|
|
308
|
+
strokeWidth={1}
|
|
309
|
+
strokeDasharray="4 3"
|
|
310
|
+
vectorEffect="non-scaling-stroke"
|
|
311
|
+
pointerEvents="none"
|
|
312
|
+
/>
|
|
313
|
+
) : null}
|
|
314
|
+
{section.rows.map((row) => {
|
|
315
|
+
const first = row.seats[0];
|
|
316
|
+
const last = row.seats[row.seats.length - 1];
|
|
317
|
+
return (
|
|
318
|
+
<g key={row.id} data-row-id={row.id}>
|
|
319
|
+
{row.seats.map((seat) => (
|
|
320
|
+
<SeatDot key={seat.id} seat={seat} {...common} />
|
|
321
|
+
))}
|
|
322
|
+
{showRowLabels && first && rowLabelSide !== "none" ? (
|
|
323
|
+
<RowLabels label={row.label} first={first} last={last ?? first} side={rowLabelSide} offset={common.seatRadius * 2.4} fontSize={rowFont} />
|
|
324
|
+
) : null}
|
|
325
|
+
</g>
|
|
326
|
+
);
|
|
327
|
+
})}
|
|
328
|
+
{labelAt ? (
|
|
329
|
+
<text
|
|
330
|
+
x={labelAt.x}
|
|
331
|
+
y={labelAt.y}
|
|
332
|
+
fontSize={labelFont}
|
|
333
|
+
fontWeight={600}
|
|
334
|
+
textAnchor="middle"
|
|
335
|
+
dominantBaseline="central"
|
|
336
|
+
fill={LABEL_COLOR}
|
|
337
|
+
fillOpacity={0.6}
|
|
338
|
+
pointerEvents="none"
|
|
339
|
+
style={{ userSelect: "none" }}
|
|
340
|
+
>
|
|
341
|
+
{section.name}
|
|
342
|
+
</text>
|
|
343
|
+
) : null}
|
|
344
|
+
</g>
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const TableLayer = memo(function TableLayer({ table, ...common }: SeatLayerCommon & { table: Table }) {
|
|
349
|
+
const tint = sectionTint(table.seats, common) ?? "#9ca3af";
|
|
350
|
+
const width = table.width ?? 80;
|
|
351
|
+
const height = table.height ?? 50;
|
|
352
|
+
const bodyProps = {
|
|
353
|
+
fill: tint,
|
|
354
|
+
fillOpacity: 0.18,
|
|
355
|
+
stroke: tint,
|
|
356
|
+
strokeOpacity: 0.6,
|
|
357
|
+
strokeWidth: 1,
|
|
358
|
+
vectorEffect: "non-scaling-stroke" as const,
|
|
359
|
+
pointerEvents: "none" as const,
|
|
360
|
+
};
|
|
361
|
+
return (
|
|
362
|
+
<g data-object-id={table.id} data-object-type="table" data-testid="seat-map-table">
|
|
363
|
+
{table.tableShape === "round" ? (
|
|
364
|
+
<circle cx={table.center.x} cy={table.center.y} r={table.radius ?? 30} {...bodyProps} />
|
|
365
|
+
) : (
|
|
366
|
+
<rect
|
|
367
|
+
x={table.center.x - width / 2}
|
|
368
|
+
y={table.center.y - height / 2}
|
|
369
|
+
width={width}
|
|
370
|
+
height={height}
|
|
371
|
+
rx={4}
|
|
372
|
+
transform={table.rotation ? `rotate(${table.rotation} ${table.center.x} ${table.center.y})` : undefined}
|
|
373
|
+
{...bodyProps}
|
|
374
|
+
/>
|
|
375
|
+
)}
|
|
376
|
+
{table.seats.map((seat) => (
|
|
377
|
+
<SeatDot key={seat.id} seat={seat} {...common} />
|
|
378
|
+
))}
|
|
379
|
+
<text
|
|
380
|
+
x={table.center.x}
|
|
381
|
+
y={table.center.y}
|
|
382
|
+
fontSize={Math.max(9, common.seatRadius * 1.4)}
|
|
383
|
+
fontWeight={600}
|
|
384
|
+
textAnchor="middle"
|
|
385
|
+
dominantBaseline="central"
|
|
386
|
+
fill={LABEL_COLOR}
|
|
387
|
+
fillOpacity={0.75}
|
|
388
|
+
pointerEvents="none"
|
|
389
|
+
style={{ userSelect: "none" }}
|
|
390
|
+
>
|
|
391
|
+
{table.name}
|
|
392
|
+
</text>
|
|
393
|
+
</g>
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
/** The tier colour of the first seat of the object that has one, for the object's own tint. */
|
|
398
|
+
const sectionTint = (seats: readonly Seat[], common: Pick<SeatLayerCommon, "bySource" | "tierColors">): string | null => {
|
|
399
|
+
for (const seat of seats) {
|
|
400
|
+
const event = common.bySource.get(seat.id);
|
|
401
|
+
const color = event ? common.tierColors[event.eventTicketId] : undefined;
|
|
402
|
+
if (color) return color;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* One seat: a visible circle and, behind it, an invisible larger one that
|
|
409
|
+
* takes the tap on a phone. The `data-seat-source` attribute on the group is
|
|
410
|
+
* what the canvas resolves a pointer event to.
|
|
411
|
+
*/
|
|
412
|
+
const SeatDot = memo(function SeatDot({
|
|
413
|
+
seat,
|
|
414
|
+
bySource,
|
|
415
|
+
tierColors,
|
|
416
|
+
selectedIds,
|
|
417
|
+
hoveredSource,
|
|
418
|
+
primary,
|
|
419
|
+
seatRadius,
|
|
420
|
+
tapRadius,
|
|
421
|
+
showSeatLabels,
|
|
422
|
+
t,
|
|
423
|
+
}: SeatLayerCommon & { seat: Seat }) {
|
|
424
|
+
const event = bySource.get(seat.id);
|
|
425
|
+
const selected = !!event && selectedIds.has(event.id);
|
|
426
|
+
const hovered = hoveredSource === seat.id;
|
|
427
|
+
const paint = seatPaint(event, tierColors, { selected, hovered, primary });
|
|
428
|
+
const ringWidth = selected ? seatRadius * 0.45 : hovered ? seatRadius * 0.3 : 0;
|
|
429
|
+
const status = selected
|
|
430
|
+
? t("forge.seat_map_canvas.status_selected")
|
|
431
|
+
: paint.interactive
|
|
432
|
+
? t("forge.seat_map_canvas.status_available")
|
|
433
|
+
: t("forge.seat_map_canvas.status_unavailable");
|
|
434
|
+
const name = event?.label ?? seat.label;
|
|
435
|
+
const accessible = !!(seat.attributes?.accessible || event?.attributes?.accessible);
|
|
436
|
+
return (
|
|
437
|
+
<g
|
|
438
|
+
{...{ [SOURCE_ATTR]: seat.id }}
|
|
439
|
+
data-testid="seat-map-seat"
|
|
440
|
+
data-status={selected ? "selected" : paint.interactive ? "available" : "unavailable"}
|
|
441
|
+
role="button"
|
|
442
|
+
aria-label={accessible ? `${name}, ${status}, ${t("forge.seat_map_canvas.accessible")}` : `${name}, ${status}`}
|
|
443
|
+
aria-pressed={selected}
|
|
444
|
+
aria-disabled={paint.interactive ? undefined : true}
|
|
445
|
+
tabIndex={-1}
|
|
446
|
+
>
|
|
447
|
+
<title>{accessible ? `${name} · ${status} · ${t("forge.seat_map_canvas.accessible")}` : `${name} · ${status}`}</title>
|
|
448
|
+
<circle cx={seat.x} cy={seat.y} r={tapRadius} fill="transparent" />
|
|
449
|
+
<circle
|
|
450
|
+
cx={seat.x}
|
|
451
|
+
cy={seat.y}
|
|
452
|
+
r={seatRadius}
|
|
453
|
+
fill={paint.fill}
|
|
454
|
+
fillOpacity={paint.fillOpacity}
|
|
455
|
+
stroke={paint.ring ?? "none"}
|
|
456
|
+
strokeWidth={ringWidth}
|
|
457
|
+
pointerEvents="none"
|
|
458
|
+
/>
|
|
459
|
+
{selected ? (
|
|
460
|
+
<circle cx={seat.x} cy={seat.y} r={seatRadius + ringWidth + 1.5} fill="none" stroke={primary} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
|
461
|
+
) : null}
|
|
462
|
+
{accessible ? (
|
|
463
|
+
<circle cx={seat.x} cy={seat.y} r={seatRadius * 0.32} fill="#ffffff" pointerEvents="none" />
|
|
464
|
+
) : null}
|
|
465
|
+
{showSeatLabels && !accessible ? (
|
|
466
|
+
<text
|
|
467
|
+
x={seat.x}
|
|
468
|
+
y={seat.y}
|
|
469
|
+
fontSize={seatRadius * 1.1}
|
|
470
|
+
textAnchor="middle"
|
|
471
|
+
dominantBaseline="central"
|
|
472
|
+
fill={paint.interactive || selected ? "#ffffff" : MUTED_LABEL_COLOR}
|
|
473
|
+
pointerEvents="none"
|
|
474
|
+
style={{ userSelect: "none" }}
|
|
475
|
+
>
|
|
476
|
+
{seat.label}
|
|
477
|
+
</text>
|
|
478
|
+
) : null}
|
|
479
|
+
</g>
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
interface RowLabelsProps {
|
|
484
|
+
label: string;
|
|
485
|
+
first: { x: number; y: number };
|
|
486
|
+
last: { x: number; y: number };
|
|
487
|
+
side: "left" | "right" | "both";
|
|
488
|
+
offset: number;
|
|
489
|
+
fontSize: number;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Row letters just outside the first and last seat, along the row's own direction so rotated rows still read. */
|
|
493
|
+
function RowLabels({ label, first, last, side, offset, fontSize }: RowLabelsProps) {
|
|
494
|
+
const dx = last.x - first.x;
|
|
495
|
+
const dy = last.y - first.y;
|
|
496
|
+
const length = Math.hypot(dx, dy);
|
|
497
|
+
const ux = length === 0 ? 1 : dx / length;
|
|
498
|
+
const uy = length === 0 ? 0 : dy / length;
|
|
499
|
+
const common = {
|
|
500
|
+
fontSize,
|
|
501
|
+
fill: MUTED_LABEL_COLOR,
|
|
502
|
+
textAnchor: "middle" as const,
|
|
503
|
+
dominantBaseline: "central" as const,
|
|
504
|
+
pointerEvents: "none" as const,
|
|
505
|
+
style: { userSelect: "none" as const },
|
|
506
|
+
};
|
|
507
|
+
return (
|
|
508
|
+
<>
|
|
509
|
+
{side === "left" || side === "both" ? (
|
|
510
|
+
<text x={first.x - ux * offset} y={first.y - uy * offset} {...common}>
|
|
511
|
+
{label}
|
|
512
|
+
</text>
|
|
513
|
+
) : null}
|
|
514
|
+
{side === "right" || side === "both" ? (
|
|
515
|
+
<text x={last.x + ux * offset} y={last.y + uy * offset} {...common}>
|
|
516
|
+
{label}
|
|
517
|
+
</text>
|
|
518
|
+
) : null}
|
|
519
|
+
</>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Renders any layout `Shape` as the matching SVG element. */
|
|
524
|
+
function ShapeElement({
|
|
525
|
+
shape,
|
|
526
|
+
rotation,
|
|
527
|
+
rx,
|
|
528
|
+
...rest
|
|
529
|
+
}: { shape: Shape; rotation?: number; rx?: number } & Omit<SVGProps<SVGElement>, "ref" | "points" | "shape">) {
|
|
530
|
+
const transform =
|
|
531
|
+
rotation && (shape.kind === "rect" || shape.kind === "ellipse")
|
|
532
|
+
? (() => {
|
|
533
|
+
const center = rectCenter(shapeBoundingBox(shape));
|
|
534
|
+
return `rotate(${rotation} ${center.x} ${center.y})`;
|
|
535
|
+
})()
|
|
536
|
+
: undefined;
|
|
537
|
+
const props = rest as SVGProps<SVGRectElement> & SVGProps<SVGEllipseElement> & SVGProps<SVGPolygonElement>;
|
|
538
|
+
switch (shape.kind) {
|
|
539
|
+
case "rect":
|
|
540
|
+
return <rect x={shape.x} y={shape.y} width={shape.width} height={shape.height} rx={rx} transform={transform} {...props} />;
|
|
541
|
+
case "ellipse":
|
|
542
|
+
return <ellipse cx={shape.cx} cy={shape.cy} rx={shape.rx} ry={shape.ry} transform={transform} {...props} />;
|
|
543
|
+
case "polygon":
|
|
544
|
+
return <polygon points={pointsAttr(shape.points)} {...props} />;
|
|
545
|
+
case "line":
|
|
546
|
+
return <polyline points={pointsAttr(shape.points)} fill="none" {...props} />;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** A general admission area: a translucent shape, its name, and a chip saying it is not seat-picked. */
|
|
551
|
+
const GaLayer = memo(function GaLayer({ ga, chip }: { ga: GaArea; chip: string }) {
|
|
552
|
+
const color = ga.color ?? "#6b7280";
|
|
553
|
+
const box = shapeBoundingBox(ga.shape);
|
|
554
|
+
const label = ga.labelPosition ?? rectCenter(box);
|
|
555
|
+
const fontSize = Math.max(10, Math.min(18, Math.min(box.width, box.height) / 6));
|
|
556
|
+
const chipFont = fontSize * 0.7;
|
|
557
|
+
const chipWidth = chip.length * chipFont * 0.6 + chipFont * 1.2;
|
|
558
|
+
return (
|
|
559
|
+
<g data-object-id={ga.id} data-object-type="ga" data-testid="seat-map-ga">
|
|
560
|
+
<ShapeElement shape={ga.shape} fill={color} fillOpacity={0.18} stroke={color} strokeOpacity={0.6} strokeWidth={1} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
|
561
|
+
<text
|
|
562
|
+
x={label.x}
|
|
563
|
+
y={label.y - fontSize * 0.55}
|
|
564
|
+
fontSize={fontSize}
|
|
565
|
+
fontWeight={600}
|
|
566
|
+
textAnchor="middle"
|
|
567
|
+
dominantBaseline="central"
|
|
568
|
+
fill={LABEL_COLOR}
|
|
569
|
+
pointerEvents="none"
|
|
570
|
+
style={{ userSelect: "none" }}
|
|
571
|
+
>
|
|
572
|
+
{ga.name}
|
|
573
|
+
</text>
|
|
574
|
+
<rect
|
|
575
|
+
x={label.x - chipWidth / 2}
|
|
576
|
+
y={label.y + fontSize * 0.2}
|
|
577
|
+
width={chipWidth}
|
|
578
|
+
height={chipFont * 1.6}
|
|
579
|
+
rx={chipFont * 0.8}
|
|
580
|
+
fill={LABEL_COLOR}
|
|
581
|
+
fillOpacity={0.75}
|
|
582
|
+
pointerEvents="none"
|
|
583
|
+
/>
|
|
584
|
+
<text
|
|
585
|
+
x={label.x}
|
|
586
|
+
y={label.y + fontSize * 0.2 + chipFont * 0.8}
|
|
587
|
+
fontSize={chipFont}
|
|
588
|
+
fontWeight={600}
|
|
589
|
+
textAnchor="middle"
|
|
590
|
+
dominantBaseline="central"
|
|
591
|
+
fill="#ffffff"
|
|
592
|
+
pointerEvents="none"
|
|
593
|
+
style={{ userSelect: "none" }}
|
|
594
|
+
>
|
|
595
|
+
{chip}
|
|
596
|
+
</text>
|
|
597
|
+
</g>
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const DecorLayer = memo(function DecorLayer({ decor }: { decor: Decor }) {
|
|
602
|
+
const style = decor.style ?? {};
|
|
603
|
+
const isStage = decor.kind === "stage";
|
|
604
|
+
const isLine = decor.shape.kind === "line";
|
|
605
|
+
const box = shapeBoundingBox(decor.shape);
|
|
606
|
+
const center = rectCenter(box);
|
|
607
|
+
return (
|
|
608
|
+
<g data-object-id={decor.id} data-object-type="shape" data-testid="seat-map-decor" opacity={style.opacity ?? 1} pointerEvents="none">
|
|
609
|
+
<ShapeElement
|
|
610
|
+
shape={decor.shape}
|
|
611
|
+
rotation={decor.rotation}
|
|
612
|
+
rx={isStage ? 8 : undefined}
|
|
613
|
+
fill={isLine ? "none" : (style.fill ?? (isStage ? STAGE_FILL : DECOR_DEFAULTS.fill))}
|
|
614
|
+
stroke={style.stroke ?? (isStage ? "none" : DECOR_DEFAULTS.stroke)}
|
|
615
|
+
strokeWidth={style.strokeWidth ?? (isLine ? 2 : DECOR_DEFAULTS.strokeWidth)}
|
|
616
|
+
strokeLinecap="round"
|
|
617
|
+
strokeLinejoin="round"
|
|
618
|
+
/>
|
|
619
|
+
{decor.label ? (
|
|
620
|
+
<text
|
|
621
|
+
x={center.x}
|
|
622
|
+
y={center.y}
|
|
623
|
+
fontSize={Math.max(10, Math.min(22, box.height / 2.5))}
|
|
624
|
+
fontWeight={600}
|
|
625
|
+
textAnchor="middle"
|
|
626
|
+
dominantBaseline="central"
|
|
627
|
+
fill={isStage ? "#f9fafb" : LABEL_COLOR}
|
|
628
|
+
transform={decor.rotation ? `rotate(${decor.rotation} ${center.x} ${center.y})` : undefined}
|
|
629
|
+
style={{ userSelect: "none" }}
|
|
630
|
+
>
|
|
631
|
+
{decor.label}
|
|
632
|
+
</text>
|
|
633
|
+
) : null}
|
|
634
|
+
</g>
|
|
635
|
+
);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const TextLayer = memo(function TextLayer({ text }: { text: TextObject }) {
|
|
639
|
+
const transform = text.rotation ? `rotate(${text.rotation} ${text.position.x} ${text.position.y})` : undefined;
|
|
640
|
+
return (
|
|
641
|
+
<g data-object-id={text.id} data-object-type="text" data-testid="seat-map-text" transform={transform} pointerEvents="none">
|
|
642
|
+
<text x={text.position.x} y={text.position.y} fontSize={text.fontSize} fill={text.color ?? LABEL_COLOR} style={{ userSelect: "none" }}>
|
|
643
|
+
{text.text}
|
|
644
|
+
</text>
|
|
645
|
+
</g>
|
|
646
|
+
);
|
|
647
|
+
});
|