@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
@@ -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
+ });