@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,79 @@
1
+ import type { EventSeat } from "../../headless/event/useEventSeating";
2
+
3
+ /**
4
+ * Colours for the buyer's seat map.
5
+ *
6
+ * Seats are coloured by TIER, never by the layout's own category colour: the
7
+ * buyer is choosing a price, and the category palette was picked by whoever
8
+ * drew the venue with no thought for this event's tiers. The palette is
9
+ * indexed by the tier's position in `event.tickets`, so the chip in the legend,
10
+ * the swatch on the tier row and the seat on the map are the same colour by
11
+ * construction. These are the one place a Forge component hard-codes colour:
12
+ * a theme has one primary and a map needs a dozen distinguishable hues.
13
+ */
14
+ export const TIER_PALETTE: readonly string[] = [
15
+ "#2563eb",
16
+ "#db2777",
17
+ "#16a34a",
18
+ "#ea580c",
19
+ "#7c3aed",
20
+ "#0891b2",
21
+ "#ca8a04",
22
+ "#dc2626",
23
+ "#0d9488",
24
+ "#9333ea",
25
+ "#4f46e5",
26
+ "#65a30d",
27
+ ];
28
+
29
+ /** Held, sold, blocked: all the same grey, because a buyer must not tell them apart. */
30
+ export const UNAVAILABLE_FILL = "#cbd5e1";
31
+ /** A seat the layout has but the event does not sell (no tier mapped). */
32
+ export const UNSOLD_FILL = "#e5e7eb";
33
+ export const STAGE_FILL = "#1f2937";
34
+ export const DECOR_DEFAULTS = { fill: "#e5e7eb", stroke: "#9ca3af", strokeWidth: 1 } as const;
35
+ export const LABEL_COLOR = "#111827";
36
+ export const MUTED_LABEL_COLOR = "#374151";
37
+
38
+ /** Seat labels are unreadable below this zoom, and thousands of `<text>` nodes are not free. */
39
+ export const SEAT_LABEL_MIN_SCALE = 1.6;
40
+ export const ROW_LABEL_MIN_SCALE = 0.8;
41
+ /** A seat never shrinks below 3 screen pixels, or a zoomed-out map turns to dust. */
42
+ export const MIN_SEAT_SCREEN_PX = 3;
43
+ /** A finger needs more than a 6-unit seat to land on. */
44
+ export const MIN_TAP_TARGET_PX = 22;
45
+
46
+ /** Tier id -> colour, by the tier's position in the event's own list. */
47
+ export const tierColorMap = (tierIds: readonly string[]): Record<string, string> => {
48
+ const out: Record<string, string> = {};
49
+ tierIds.forEach((id, index) => {
50
+ out[id] = TIER_PALETTE[index % TIER_PALETTE.length]!;
51
+ });
52
+ return out;
53
+ };
54
+
55
+ export interface SeatPaint {
56
+ fill: string;
57
+ fillOpacity: number;
58
+ /** Ring colour, or null for none. */
59
+ ring: string | null;
60
+ interactive: boolean;
61
+ }
62
+
63
+ /**
64
+ * How one seat is drawn. `selected` wins (the buyer's own seats stay coloured
65
+ * even once the hold makes them read unavailable), then unavailable grey,
66
+ * then the tier's colour, then the "not for sale" grey for a seat the event
67
+ * never mapped.
68
+ */
69
+ export const seatPaint = (
70
+ seat: EventSeat | undefined,
71
+ tierColors: Readonly<Record<string, string>>,
72
+ state: { selected: boolean; hovered: boolean; primary: string },
73
+ ): SeatPaint => {
74
+ const tier = seat ? tierColors[seat.eventTicketId] : undefined;
75
+ if (state.selected) return { fill: tier ?? state.primary, fillOpacity: 1, ring: state.primary, interactive: true };
76
+ if (!seat || !tier) return { fill: UNSOLD_FILL, fillOpacity: 1, ring: null, interactive: false };
77
+ if (seat.status !== "available") return { fill: UNAVAILABLE_FILL, fillOpacity: 1, ring: null, interactive: false };
78
+ return { fill: tier, fillOpacity: state.hovered ? 0.8 : 1, ring: state.hovered ? state.primary : null, interactive: true };
79
+ };
@@ -0,0 +1,248 @@
1
+ import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
2
+ import type { Point, Rect } from "@tribe-nest/seating-core";
3
+ import {
4
+ fitToRect,
5
+ looksLikeMouseWheel,
6
+ movedEnough,
7
+ normalizeWheelDelta,
8
+ panBy,
9
+ wheelZoomFactor,
10
+ zoomAt,
11
+ type Viewport,
12
+ } from "./viewport";
13
+
14
+ export interface UseSeatMapViewportOptions {
15
+ /** The element the canvas fills; its box is the screen space. */
16
+ containerRef: RefObject<HTMLElement | null>;
17
+ /** The world rectangle "fit" shows. Refit happens when its identity changes. */
18
+ fitRect: Rect;
19
+ /**
20
+ * A tap (a press that never moved). Receives the pointer's target, so the
21
+ * caller can resolve what was under the finger, and the screen point.
22
+ */
23
+ onTap?: (target: EventTarget | null, screen: Point) => void;
24
+ /** Two taps within this many ms, this close, zoom in instead of tapping twice. */
25
+ doubleTapMs?: number;
26
+ }
27
+
28
+ export interface SeatMapViewportControls {
29
+ viewport: Viewport;
30
+ size: { width: number; height: number };
31
+ zoomIn: () => void;
32
+ zoomOut: () => void;
33
+ fit: () => void;
34
+ /** True while a finger or button is dragging the map. */
35
+ isPanning: boolean;
36
+ /** Spread onto the `<svg>`. */
37
+ pointerHandlers: {
38
+ onPointerDown: (event: ReactPointerEvent<SVGSVGElement>) => void;
39
+ onPointerMove: (event: ReactPointerEvent<SVGSVGElement>) => void;
40
+ onPointerUp: (event: ReactPointerEvent<SVGSVGElement>) => void;
41
+ onPointerCancel: (event: ReactPointerEvent<SVGSVGElement>) => void;
42
+ };
43
+ }
44
+
45
+ const ZOOM_STEP = 1.4;
46
+ const DOUBLE_TAP_ZOOM = 2;
47
+
48
+ type Gesture =
49
+ | { kind: "press"; pointerId: number; start: Point; last: Point; target: EventTarget | null; moved: boolean }
50
+ | { kind: "pinch"; distance: number; center: Point };
51
+
52
+ /**
53
+ * Pan, zoom and tap for the buyer's seat map.
54
+ *
55
+ * Wheel zooms to the cursor (a trackpad's two-finger scroll pans, a pinch on
56
+ * a trackpad arrives as ctrl+wheel and zooms), a drag pans, two fingers pinch
57
+ * to zoom about their midpoint, a tap reports what was under it, and a double
58
+ * tap zooms in where it landed, which is how a phone user gets from the whole
59
+ * room to one row. Wheel is registered natively because React's listener is
60
+ * passive and cannot stop the page scrolling underneath the map.
61
+ *
62
+ * The container must carry `touch-action: none`, or the browser claims the
63
+ * pan gesture for page scroll before a single `pointermove` arrives.
64
+ */
65
+ export function useSeatMapViewport({
66
+ containerRef,
67
+ fitRect,
68
+ onTap,
69
+ doubleTapMs = 300,
70
+ }: UseSeatMapViewportOptions): SeatMapViewportControls {
71
+ const [size, setSize] = useState({ width: 640, height: 360 });
72
+ const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, scale: 1 });
73
+ const [isPanning, setIsPanning] = useState(false);
74
+ const gestureRef = useRef<Gesture | null>(null);
75
+ const pointersRef = useRef<Map<number, Point>>(new Map());
76
+ const lastTapRef = useRef<{ at: number; screen: Point } | null>(null);
77
+ const onTapRef = useRef(onTap);
78
+ onTapRef.current = onTap;
79
+
80
+ // Measure the container, and keep measuring: the modal it sits in resizes
81
+ // with the window, and a map fitted to a stale width sits off-centre.
82
+ useEffect(() => {
83
+ const element = containerRef.current;
84
+ if (!element) return;
85
+ const update = () => {
86
+ const bounds = element.getBoundingClientRect();
87
+ if (bounds.width > 0 && bounds.height > 0) setSize({ width: bounds.width, height: bounds.height });
88
+ };
89
+ update();
90
+ if (typeof ResizeObserver === "undefined") return;
91
+ const observer = new ResizeObserver(update);
92
+ observer.observe(element);
93
+ return () => observer.disconnect();
94
+ }, [containerRef]);
95
+
96
+ const fit = useCallback(() => setViewport(fitToRect(fitRect, size)), [fitRect, size]);
97
+
98
+ // Fit once per layout and whenever the container is first measured. Not on
99
+ // every resize after that: a buyer who has zoomed into row F must not be
100
+ // thrown back to the whole room because the keyboard opened.
101
+ const fittedFor = useRef<{ rect: Rect; sized: boolean } | null>(null);
102
+ useEffect(() => {
103
+ const measured = size.width > 0 && size.height > 0;
104
+ const previous = fittedFor.current;
105
+ if (previous && previous.rect === fitRect && previous.sized) return;
106
+ fittedFor.current = { rect: fitRect, sized: measured };
107
+ setViewport(fitToRect(fitRect, size));
108
+ }, [fitRect, size]);
109
+
110
+ const toScreen = useCallback(
111
+ (event: { clientX: number; clientY: number }): Point => {
112
+ const bounds = containerRef.current?.getBoundingClientRect();
113
+ return { x: event.clientX - (bounds?.left ?? 0), y: event.clientY - (bounds?.top ?? 0) };
114
+ },
115
+ [containerRef],
116
+ );
117
+
118
+ useEffect(() => {
119
+ const element = containerRef.current;
120
+ if (!element) return;
121
+ const onWheel = (event: WheelEvent) => {
122
+ event.preventDefault();
123
+ const screen = toScreen(event);
124
+ const delta = normalizeWheelDelta(event);
125
+ if (event.ctrlKey || event.metaKey) {
126
+ // Pinch deltas are small; scale them up so a pinch feels like a pinch.
127
+ setViewport((current) => zoomAt(current, wheelZoomFactor(delta.y * 3), screen));
128
+ return;
129
+ }
130
+ if (looksLikeMouseWheel(event)) {
131
+ setViewport((current) => zoomAt(current, wheelZoomFactor(delta.y), screen));
132
+ return;
133
+ }
134
+ setViewport((current) => panBy(current, -delta.x, -delta.y));
135
+ };
136
+ element.addEventListener("wheel", onWheel, { passive: false });
137
+ return () => element.removeEventListener("wheel", onWheel);
138
+ }, [containerRef, toScreen]);
139
+
140
+ const center = useCallback((): Point => ({ x: size.width / 2, y: size.height / 2 }), [size]);
141
+ const zoomIn = useCallback(() => setViewport((current) => zoomAt(current, ZOOM_STEP, center())), [center]);
142
+ const zoomOut = useCallback(() => setViewport((current) => zoomAt(current, 1 / ZOOM_STEP, center())), [center]);
143
+
144
+ const pinchState = () => {
145
+ const points = [...pointersRef.current.values()];
146
+ if (points.length < 2) return null;
147
+ const [a, b] = points as [Point, Point];
148
+ return { distance: Math.hypot(b.x - a.x, b.y - a.y) || 1, center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } };
149
+ };
150
+
151
+ const onPointerDown = (event: ReactPointerEvent<SVGSVGElement>) => {
152
+ // The primary button, or any touch. A right click is the browser's.
153
+ if (event.pointerType === "mouse" && event.button !== 0) return;
154
+ try {
155
+ event.currentTarget.setPointerCapture(event.pointerId);
156
+ } catch {
157
+ // No pointer capture in this environment; fine.
158
+ }
159
+ const screen = toScreen(event);
160
+ pointersRef.current.set(event.pointerId, screen);
161
+ const pinch = pinchState();
162
+ if (pinch) {
163
+ gestureRef.current = { kind: "pinch", ...pinch };
164
+ setIsPanning(true);
165
+ return;
166
+ }
167
+ gestureRef.current = { kind: "press", pointerId: event.pointerId, start: screen, last: screen, target: event.target, moved: false };
168
+ };
169
+
170
+ const onPointerMove = (event: ReactPointerEvent<SVGSVGElement>) => {
171
+ if (!pointersRef.current.has(event.pointerId)) return;
172
+ const screen = toScreen(event);
173
+ pointersRef.current.set(event.pointerId, screen);
174
+ const gesture = gestureRef.current;
175
+ if (!gesture) return;
176
+ if (gesture.kind === "pinch") {
177
+ const pinch = pinchState();
178
+ if (!pinch) return;
179
+ const factor = pinch.distance / gesture.distance;
180
+ setViewport((current) => {
181
+ const zoomed = zoomAt(current, factor, pinch.center);
182
+ return panBy(zoomed, pinch.center.x - gesture.center.x, pinch.center.y - gesture.center.y);
183
+ });
184
+ gesture.distance = pinch.distance;
185
+ gesture.center = pinch.center;
186
+ return;
187
+ }
188
+ if (gesture.pointerId !== event.pointerId) return;
189
+ if (!gesture.moved && !movedEnough(gesture.start, screen)) return;
190
+ if (!gesture.moved) {
191
+ gesture.moved = true;
192
+ setIsPanning(true);
193
+ }
194
+ const dx = screen.x - gesture.last.x;
195
+ const dy = screen.y - gesture.last.y;
196
+ gesture.last = screen;
197
+ setViewport((current) => panBy(current, dx, dy));
198
+ };
199
+
200
+ const endPointer = (event: ReactPointerEvent<SVGSVGElement>, cancelled: boolean) => {
201
+ pointersRef.current.delete(event.pointerId);
202
+ const gesture = gestureRef.current;
203
+ if (!gesture) return;
204
+ if (gesture.kind === "pinch") {
205
+ // One finger left: carry on as a pan from where it is, never as a tap.
206
+ const remaining = [...pointersRef.current.entries()][0];
207
+ if (remaining) {
208
+ gestureRef.current = { kind: "press", pointerId: remaining[0], start: remaining[1], last: remaining[1], target: null, moved: true };
209
+ } else {
210
+ gestureRef.current = null;
211
+ setIsPanning(false);
212
+ }
213
+ return;
214
+ }
215
+ if (gesture.pointerId !== event.pointerId) return;
216
+ gestureRef.current = null;
217
+ setIsPanning(false);
218
+ if (cancelled || gesture.moved) return;
219
+ const screen = toScreen(event);
220
+ const now = Date.now();
221
+ const last = lastTapRef.current;
222
+ // Touch and pen only: a mouse user has the wheel and the buttons, and a
223
+ // double click on a seat should not read as "toggle, then zoom".
224
+ const isTouch = event.pointerType === "touch" || event.pointerType === "pen";
225
+ if (isTouch && last && now - last.at <= doubleTapMs && !movedEnough(last.screen, screen, 24)) {
226
+ lastTapRef.current = null;
227
+ setViewport((current) => zoomAt(current, DOUBLE_TAP_ZOOM, screen));
228
+ return;
229
+ }
230
+ lastTapRef.current = { at: now, screen };
231
+ onTapRef.current?.(gesture.target, screen);
232
+ };
233
+
234
+ return {
235
+ viewport,
236
+ size,
237
+ zoomIn,
238
+ zoomOut,
239
+ fit,
240
+ isPanning,
241
+ pointerHandlers: {
242
+ onPointerDown,
243
+ onPointerMove,
244
+ onPointerUp: (event) => endPointer(event, false),
245
+ onPointerCancel: (event) => endPointer(event, true),
246
+ },
247
+ };
248
+ }
@@ -0,0 +1,86 @@
1
+ import type { Point, Rect } from "@tribe-nest/seating-core";
2
+
3
+ /**
4
+ * The viewport maps WORLD units (the layout's coordinates) to SCREEN pixels
5
+ * inside the canvas element: `screen = world * scale + offset`. Pure, so the
6
+ * gesture arithmetic is testable without a DOM. Mirrors the admin editor's
7
+ * viewport so a map pans and zooms the same way on both surfaces.
8
+ */
9
+ export interface Viewport {
10
+ x: number;
11
+ y: number;
12
+ scale: number;
13
+ }
14
+
15
+ export const MIN_SCALE = 0.05;
16
+ export const MAX_SCALE = 8;
17
+
18
+ export const clampScale = (scale: number): number => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
19
+
20
+ export const screenToWorld = (viewport: Viewport, screen: Point): Point => ({
21
+ x: (screen.x - viewport.x) / viewport.scale,
22
+ y: (screen.y - viewport.y) / viewport.scale,
23
+ });
24
+
25
+ export const worldToScreen = (viewport: Viewport, world: Point): Point => ({
26
+ x: world.x * viewport.scale + viewport.x,
27
+ y: world.y * viewport.scale + viewport.y,
28
+ });
29
+
30
+ /**
31
+ * Zoom by `factor` keeping the world point under `screen` where it is. This is
32
+ * what makes wheel and pinch zoom feel anchored: solve for the offset that
33
+ * puts the same world point back under the cursor after the scale changes.
34
+ */
35
+ export const zoomAt = (viewport: Viewport, factor: number, screen: Point): Viewport => {
36
+ const scale = clampScale(viewport.scale * factor);
37
+ if (scale === viewport.scale) return viewport;
38
+ const anchor = screenToWorld(viewport, screen);
39
+ return { scale, x: screen.x - anchor.x * scale, y: screen.y - anchor.y * scale };
40
+ };
41
+
42
+ export const panBy = (viewport: Viewport, dx: number, dy: number): Viewport => ({
43
+ ...viewport,
44
+ x: viewport.x + dx,
45
+ y: viewport.y + dy,
46
+ });
47
+
48
+ /** A viewport that shows `rect` centred with `padding` screen pixels around it. */
49
+ export const fitToRect = (rect: Rect, container: { width: number; height: number }, padding = 24): Viewport => {
50
+ const availableW = Math.max(1, container.width - padding * 2);
51
+ const availableH = Math.max(1, container.height - padding * 2);
52
+ const width = Math.max(1, rect.width);
53
+ const height = Math.max(1, rect.height);
54
+ const scale = clampScale(Math.min(availableW / width, availableH / height));
55
+ return {
56
+ scale,
57
+ x: container.width / 2 - (rect.x + rect.width / 2) * scale,
58
+ y: container.height / 2 - (rect.y + rect.height / 2) * scale,
59
+ };
60
+ };
61
+
62
+ /**
63
+ * Wheel deltas arrive in three flavours: pixel, line and page. Normalise to
64
+ * pixels so a mouse wheel and a trackpad zoom at comparable speeds.
65
+ */
66
+ export const normalizeWheelDelta = (event: { deltaY: number; deltaX: number; deltaMode: number }): Point => {
67
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1;
68
+ return { x: event.deltaX * unit, y: event.deltaY * unit };
69
+ };
70
+
71
+ /** Zoom factor for a wheel delta: exponential so equal wheel travel gives equal zoom ratios. */
72
+ export const wheelZoomFactor = (deltaY: number): number => Math.exp(-deltaY * 0.0015);
73
+
74
+ /**
75
+ * Tells a mouse wheel from a trackpad. A wheel reports whole-line or large,
76
+ * integer pixel steps with no horizontal component; a trackpad reports small
77
+ * fractional deltas in both axes. Pinch arrives as a wheel with ctrlKey set.
78
+ */
79
+ export const looksLikeMouseWheel = (event: { deltaMode: number; deltaX: number; deltaY: number }): boolean => {
80
+ if (event.deltaMode !== 0) return true;
81
+ return event.deltaX === 0 && Math.abs(event.deltaY) >= 40 && Number.isInteger(event.deltaY);
82
+ };
83
+
84
+ /** Has a pointer moved far enough, in screen pixels, to be a drag rather than a tap? */
85
+ export const movedEnough = (from: Point, to: Point, thresholdPx = 6): boolean =>
86
+ Math.hypot(to.x - from.x, to.y - from.y) >= thresholdPx;
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createEngagementTracker, scrollDepthOf, type EngagementPing } from "../engagementTracker";
3
+
4
+ /**
5
+ * The backend SUMS `engagedMs` per page and takes the MAX `scrollDepth`, so the
6
+ * contract that matters is: each ping carries only time not reported before,
7
+ * and time the visitor was not looking is never counted.
8
+ */
9
+ function setup() {
10
+ let t = 0;
11
+ const sent: EngagementPing[] = [];
12
+ const tracker = createEngagementTracker({ now: () => t, send: (p) => sent.push(p) });
13
+ return { tracker, sent, advance: (ms: number) => (t += ms) };
14
+ }
15
+
16
+ describe("createEngagementTracker", () => {
17
+ it("counts engaged time only, and reports it once", () => {
18
+ const { tracker, sent, advance } = setup();
19
+ tracker.start("/a", true);
20
+ advance(5000);
21
+ tracker.deactivate();
22
+ advance(60_000); // hidden: not counted
23
+ tracker.flush();
24
+ tracker.activate();
25
+ advance(4000);
26
+ tracker.flush();
27
+ expect(sent).toEqual([
28
+ { pathname: "/a", engagedMs: 5000, scrollDepth: 0 },
29
+ { pathname: "/a", engagedMs: 4000, scrollDepth: 0 },
30
+ ]);
31
+ });
32
+
33
+ it("skips a ping under 3 seconds with no deeper scroll, and carries the time forward", () => {
34
+ const { tracker, sent, advance } = setup();
35
+ tracker.start("/a", true);
36
+ advance(2000);
37
+ tracker.flush();
38
+ expect(sent).toEqual([]);
39
+ advance(1500);
40
+ tracker.flush();
41
+ expect(sent).toEqual([{ pathname: "/a", engagedMs: 3500, scrollDepth: 0 }]);
42
+ });
43
+
44
+ it("a deeper scroll is worth a ping on its own; the same depth again is not", () => {
45
+ const { tracker, sent, advance } = setup();
46
+ tracker.start("/a", true);
47
+ tracker.scrolled(64.4);
48
+ advance(500);
49
+ tracker.flush();
50
+ tracker.scrolled(40);
51
+ tracker.flush();
52
+ expect(sent).toEqual([{ pathname: "/a", engagedMs: 500, scrollDepth: 64 }]);
53
+ });
54
+
55
+ it("clamps scroll depth to 0..100 and ignores garbage", () => {
56
+ const { tracker, sent } = setup();
57
+ tracker.start("/a", false);
58
+ tracker.scrolled(Number.NaN);
59
+ tracker.scrolled(250);
60
+ tracker.flush();
61
+ expect(sent[0].scrollDepth).toBe(100);
62
+ });
63
+
64
+ it("a new page starts from zero and never inherits the previous page's time", () => {
65
+ const { tracker, sent, advance } = setup();
66
+ tracker.start("/a", true);
67
+ advance(8000);
68
+ tracker.flush();
69
+ tracker.start("/b", true);
70
+ advance(3000);
71
+ tracker.flush();
72
+ expect(sent.map((p) => [p.pathname, p.engagedMs])).toEqual([
73
+ ["/a", 8000],
74
+ ["/b", 3000],
75
+ ]);
76
+ });
77
+
78
+ it("a page opened in the background counts nothing until it is looked at", () => {
79
+ const { tracker, sent, advance } = setup();
80
+ tracker.start("/a", false);
81
+ advance(30_000);
82
+ tracker.flush();
83
+ expect(sent).toEqual([]);
84
+ tracker.activate();
85
+ advance(3000);
86
+ tracker.flush();
87
+ expect(sent).toEqual([{ pathname: "/a", engagedMs: 3000, scrollDepth: 0 }]);
88
+ });
89
+
90
+ it("does nothing before the first page", () => {
91
+ const { tracker, sent, advance } = setup();
92
+ tracker.activate();
93
+ advance(10_000);
94
+ tracker.flush();
95
+ expect(sent).toEqual([]);
96
+ });
97
+ });
98
+
99
+ describe("scrollDepthOf", () => {
100
+ const at = (scrollY: number, innerHeight: number, scrollHeight: number) =>
101
+ scrollDepthOf({ documentElement: { scrollHeight } } as Document, { scrollY, innerHeight } as Window);
102
+
103
+ it("is how far down the viewport's bottom edge has reached", () => {
104
+ expect(at(0, 500, 2000)).toBe(25);
105
+ expect(at(1500, 500, 2000)).toBe(100);
106
+ });
107
+
108
+ it("a page shorter than the viewport has been seen in full", () => {
109
+ expect(at(0, 900, 600)).toBe(100);
110
+ });
111
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Time on page and scroll depth, measured the way Plausible measures them.
3
+ *
4
+ * Only ENGAGED time counts: the page is visible and the window has focus. A tab
5
+ * left open in the background overnight adds nothing. The time is reported as
6
+ * an `engagement` event carrying the milliseconds engaged SINCE THE LAST ONE
7
+ * (the backend sums them per page) and the deepest scroll so far (the backend
8
+ * takes the max).
9
+ *
10
+ * A ping goes out when the visitor stops engaging (tab hidden, window blurred,
11
+ * page unloading) and just before an SPA navigation, so it lands on the page it
12
+ * measured. It is skipped when there is too little to say: under 3 seconds of
13
+ * new engaged time and no deeper scroll.
14
+ *
15
+ * Pure: the caller owns the DOM listeners and the transport, which keeps this
16
+ * testable with a fake clock.
17
+ */
18
+
19
+ export const MIN_ENGAGED_MS = 3000;
20
+
21
+ export type EngagementPing = { pathname: string; engagedMs: number; scrollDepth: number };
22
+
23
+ export function createEngagementTracker(opts: { now: () => number; send: (ping: EngagementPing) => void }) {
24
+ let pathname: string | null = null;
25
+ let banked = 0;
26
+ let activeSince: number | null = null;
27
+ let maxScroll = 0;
28
+ let sentScroll = 0;
29
+
30
+ const pending = () => banked + (activeSince === null ? 0 : opts.now() - activeSince);
31
+
32
+ return {
33
+ /** A new page: anything unsent for the previous one must be flushed first. */
34
+ start(nextPathname: string, active: boolean) {
35
+ pathname = nextPathname;
36
+ banked = 0;
37
+ maxScroll = 0;
38
+ sentScroll = 0;
39
+ activeSince = active ? opts.now() : null;
40
+ },
41
+ activate() {
42
+ if (pathname !== null && activeSince === null) activeSince = opts.now();
43
+ },
44
+ deactivate() {
45
+ if (activeSince === null) return;
46
+ banked += opts.now() - activeSince;
47
+ activeSince = null;
48
+ },
49
+ scrolled(depth: number) {
50
+ if (Number.isFinite(depth)) maxScroll = Math.max(maxScroll, Math.min(100, Math.max(0, Math.round(depth))));
51
+ },
52
+ /** Send what has accumulated, if it is worth sending. Keeps the clock running when the page is still engaged. */
53
+ flush() {
54
+ if (pathname === null) return;
55
+ const engagedMs = pending();
56
+ if (engagedMs < MIN_ENGAGED_MS && maxScroll <= sentScroll) return;
57
+ opts.send({ pathname, engagedMs, scrollDepth: maxScroll });
58
+ banked = 0;
59
+ if (activeSince !== null) activeSince = opts.now();
60
+ sentScroll = maxScroll;
61
+ },
62
+ };
63
+ }
64
+
65
+ export type EngagementTracker = ReturnType<typeof createEngagementTracker>;
66
+
67
+ /** How far down the document the bottom of the viewport has reached, 0 to 100. */
68
+ export function scrollDepthOf(doc: Document, win: Window): number {
69
+ const height = doc.documentElement.scrollHeight;
70
+ if (!height) return 0;
71
+ // A page shorter than the viewport has been seen in full.
72
+ return Math.min(100, ((win.scrollY + win.innerHeight) / height) * 100);
73
+ }
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // First-touch wins with a 30-day TTL: the very first landing is preserved for
11
11
  // 30 days, matching the tracked-link `tn:attributionRef` semantics so the two
12
- // attribution signals expire together. See docs/ads-attribution-initiative.md.
12
+ // attribution signals expire together. See docs/archive/shipped/ads-attribution-initiative.md.
13
13
 
14
14
  const STORAGE_KEY = "tn:landing";
15
15
  const TTL_MS = 30 * 24 * 60 * 60 * 1000;