@posthog/twig-components 0.1.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/LICENSE +21 -0
- package/README.md +59 -0
- package/build-assets.mjs +18 -0
- package/dist/AiLab.d.ts +26 -0
- package/dist/AiLab.js +140 -0
- package/dist/BookingLab.d.ts +19 -0
- package/dist/BookingLab.js +100 -0
- package/dist/BrowseStaysPreview.d.ts +7 -0
- package/dist/BrowseStaysPreview.js +6 -0
- package/dist/FilterInspector.d.ts +7 -0
- package/dist/FilterInspector.js +27 -0
- package/dist/FilterLabExercise.d.ts +14 -0
- package/dist/FilterLabExercise.js +78 -0
- package/dist/FinishLabButton.d.ts +3 -0
- package/dist/FinishLabButton.js +5 -0
- package/dist/LabChecklist.d.ts +8 -0
- package/dist/LabChecklist.js +6 -0
- package/dist/LabChoices.d.ts +10 -0
- package/dist/LabChoices.js +7 -0
- package/dist/LabCompletionView.d.ts +8 -0
- package/dist/LabCompletionView.js +43 -0
- package/dist/LabNavigation.d.ts +9 -0
- package/dist/LabNavigation.js +7 -0
- package/dist/PlaygroundController.d.ts +31 -0
- package/dist/PlaygroundController.js +82 -0
- package/dist/PlaygroundPanels.d.ts +32 -0
- package/dist/PlaygroundPanels.js +33 -0
- package/dist/ReplayLab.d.ts +28 -0
- package/dist/ReplayLab.js +70 -0
- package/dist/ReplayRecorder.d.ts +28 -0
- package/dist/ReplayRecorder.js +357 -0
- package/dist/SessionPlayer.d.ts +5 -0
- package/dist/SessionPlayer.js +124 -0
- package/dist/StayCard.d.ts +7 -0
- package/dist/StayCard.js +5 -0
- package/dist/StayFilters.d.ts +14 -0
- package/dist/StayFilters.js +7 -0
- package/dist/StayLab.d.ts +16 -0
- package/dist/StayLab.js +47 -0
- package/dist/ai-lab.d.ts +55 -0
- package/dist/ai-lab.js +130 -0
- package/dist/assets/Halfre.ttf +0 -0
- package/dist/assets/RoundHog-Medium.woff2 +0 -0
- package/dist/assets/RoundHog-SemiBold.woff2 +0 -0
- package/dist/assets/RoundHog.woff2 +0 -0
- package/dist/assets/cabin.jpg +0 -0
- package/dist/assets/cliff.png +0 -0
- package/dist/assets/logo.svg +4 -0
- package/dist/booking-lab.d.ts +53 -0
- package/dist/booking-lab.js +134 -0
- package/dist/catalog.css +259 -0
- package/dist/catalog.d.ts +57 -0
- package/dist/catalog.js +111 -0
- package/dist/filter-lab.d.ts +62 -0
- package/dist/filter-lab.js +133 -0
- package/dist/lab.css +774 -0
- package/dist/playground.d.ts +167 -0
- package/dist/playground.js +91 -0
- package/dist/replay-lab.d.ts +19 -0
- package/dist/replay-lab.js +26 -0
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +1 -0
- package/dist/stay-lab.d.ts +32 -0
- package/dist/stay-lab.js +53 -0
- package/dist/trip-dates.d.ts +11 -0
- package/dist/trip-dates.js +49 -0
- package/docs/components.md +78 -0
- package/docs/integration.md +59 -0
- package/docs/workbench.md +37 -0
- package/package.json +241 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext, useEffect, useRef, useState, } from "react";
|
|
4
|
+
import { REPLAY_LIMIT, replayPage, searchValue } from "./replay-lab.js";
|
|
5
|
+
const ReplayContext = createContext({
|
|
6
|
+
exercise: "ghost",
|
|
7
|
+
setExercise: () => { },
|
|
8
|
+
recording: [],
|
|
9
|
+
starting: false,
|
|
10
|
+
frames: [],
|
|
11
|
+
mode: null,
|
|
12
|
+
masked: true,
|
|
13
|
+
setMasked: () => { },
|
|
14
|
+
capturedMasked: true,
|
|
15
|
+
message: "",
|
|
16
|
+
start: () => { },
|
|
17
|
+
stop: () => { },
|
|
18
|
+
clear: () => { },
|
|
19
|
+
reset: () => { },
|
|
20
|
+
resetCount: 0,
|
|
21
|
+
});
|
|
22
|
+
export const useReplay = () => useContext(ReplayContext);
|
|
23
|
+
export function ReplayProvider({ children, open, pathname, }) {
|
|
24
|
+
const [exercise, setExercise] = useState("ghost");
|
|
25
|
+
const [resetCount, setResetCount] = useState(0);
|
|
26
|
+
const [recording, setRecording] = useState([]);
|
|
27
|
+
const [starting, setStarting] = useState(false);
|
|
28
|
+
const captured = useRef([]);
|
|
29
|
+
const stopRecorder = useRef(undefined);
|
|
30
|
+
const [frames, setFrames] = useState([]);
|
|
31
|
+
const [mode, setMode] = useState(null);
|
|
32
|
+
const [masked, setMasked] = useState(true);
|
|
33
|
+
const [capturedMasked, setCapturedMasked] = useState(true);
|
|
34
|
+
const [message, setMessage] = useState("");
|
|
35
|
+
const [cursor, setCursor] = useState(null);
|
|
36
|
+
const session = useRef({ mode: null, start: 0, masked: true, count: 0 });
|
|
37
|
+
const abort = useRef(null);
|
|
38
|
+
const pageRef = useRef(pathname);
|
|
39
|
+
function stop() {
|
|
40
|
+
session.current.mode = null;
|
|
41
|
+
abort.current?.abort();
|
|
42
|
+
stopRecorder.current?.();
|
|
43
|
+
stopRecorder.current = undefined;
|
|
44
|
+
setRecording([...captured.current]);
|
|
45
|
+
setStarting(false);
|
|
46
|
+
setMode(null);
|
|
47
|
+
setCursor(null);
|
|
48
|
+
}
|
|
49
|
+
function reset() {
|
|
50
|
+
stop();
|
|
51
|
+
captured.current = [];
|
|
52
|
+
setRecording([]);
|
|
53
|
+
setFrames([]);
|
|
54
|
+
setMessage("");
|
|
55
|
+
setExercise("ghost");
|
|
56
|
+
setMasked(true);
|
|
57
|
+
setCapturedMasked(true);
|
|
58
|
+
setResetCount((value) => value + 1);
|
|
59
|
+
}
|
|
60
|
+
function add(frame) {
|
|
61
|
+
const current = session.current;
|
|
62
|
+
if (!current.mode)
|
|
63
|
+
return;
|
|
64
|
+
if (current.count >= REPLAY_LIMIT) {
|
|
65
|
+
stop();
|
|
66
|
+
setMessage("Recording limit reached. Your visit is ready to inspect.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
current.count++;
|
|
70
|
+
setFrames((previous) => [
|
|
71
|
+
...previous,
|
|
72
|
+
{
|
|
73
|
+
...frame,
|
|
74
|
+
at: Math.round(performance.now() - current.start),
|
|
75
|
+
page: pageRef.current,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
pageRef.current = pathname;
|
|
81
|
+
if (!replayPage(pathname))
|
|
82
|
+
stop();
|
|
83
|
+
else
|
|
84
|
+
add({
|
|
85
|
+
kind: "page",
|
|
86
|
+
label: pathname === "/" ? "Find a stay" : "Stay details",
|
|
87
|
+
});
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- Record route changes only.
|
|
89
|
+
}, [pathname]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!open)
|
|
92
|
+
stop();
|
|
93
|
+
}, [open]);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!mode)
|
|
96
|
+
return;
|
|
97
|
+
let lastMove = 0, lastScroll = 0;
|
|
98
|
+
const scope = (target) => target instanceof Element &&
|
|
99
|
+
!!target.closest(".vac-content, .vac-header");
|
|
100
|
+
const coords = (event) => ({
|
|
101
|
+
x: Math.round((event.clientX / window.innerWidth) * 100),
|
|
102
|
+
y: Math.round((event.clientY / window.innerHeight) * 100),
|
|
103
|
+
});
|
|
104
|
+
const move = (event) => {
|
|
105
|
+
if (session.current.mode !== "manual" ||
|
|
106
|
+
!scope(event.target) ||
|
|
107
|
+
performance.now() - lastMove < 150)
|
|
108
|
+
return;
|
|
109
|
+
lastMove = performance.now();
|
|
110
|
+
add({ kind: "move", label: "Pointer moved", ...coords(event) });
|
|
111
|
+
};
|
|
112
|
+
const click = (event) => {
|
|
113
|
+
if (!scope(event.target))
|
|
114
|
+
return;
|
|
115
|
+
const target = event.target.closest("[data-replay-label]");
|
|
116
|
+
if (target) {
|
|
117
|
+
const rect = target.getBoundingClientRect();
|
|
118
|
+
add({
|
|
119
|
+
kind: "click",
|
|
120
|
+
label: target.dataset.replayLabel.slice(0, 100),
|
|
121
|
+
...(event.detail
|
|
122
|
+
? coords(event)
|
|
123
|
+
: {
|
|
124
|
+
x: Math.round(((rect.left + rect.width / 2) / innerWidth) * 100),
|
|
125
|
+
y: Math.round(((rect.top + rect.height / 2) / innerHeight) * 100),
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const input = (event) => {
|
|
131
|
+
if (event.target instanceof HTMLInputElement &&
|
|
132
|
+
event.target.id === "stay-search")
|
|
133
|
+
add({
|
|
134
|
+
kind: "input",
|
|
135
|
+
label: "Search stays",
|
|
136
|
+
value: searchValue(event.target.value, session.current.masked),
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
const scroll = () => {
|
|
140
|
+
if (performance.now() - lastScroll < 350)
|
|
141
|
+
return;
|
|
142
|
+
lastScroll = performance.now();
|
|
143
|
+
add({ kind: "scroll", label: "Scrolled the page" });
|
|
144
|
+
};
|
|
145
|
+
const interrupt = (event) => {
|
|
146
|
+
if (event.isTrusted && session.current.mode === "ghost") {
|
|
147
|
+
stop();
|
|
148
|
+
setMessage("Ghost visit stopped because you took control. Inspect it or record your own visit.");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
document.addEventListener("pointermove", move);
|
|
152
|
+
document.addEventListener("click", click, true);
|
|
153
|
+
document.addEventListener("input", input, true);
|
|
154
|
+
window.addEventListener("scroll", scroll);
|
|
155
|
+
document.addEventListener("pointerdown", interrupt, true);
|
|
156
|
+
document.addEventListener("keydown", interrupt, true);
|
|
157
|
+
const timeout = window.setTimeout(() => {
|
|
158
|
+
stop();
|
|
159
|
+
setMessage("Two-minute limit reached. Your visit is ready to inspect.");
|
|
160
|
+
}, 120000);
|
|
161
|
+
return () => {
|
|
162
|
+
window.clearTimeout(timeout);
|
|
163
|
+
document.removeEventListener("pointermove", move);
|
|
164
|
+
document.removeEventListener("click", click, true);
|
|
165
|
+
document.removeEventListener("input", input, true);
|
|
166
|
+
window.removeEventListener("scroll", scroll);
|
|
167
|
+
document.removeEventListener("pointerdown", interrupt, true);
|
|
168
|
+
document.removeEventListener("keydown", interrupt, true);
|
|
169
|
+
};
|
|
170
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- Handlers read the current session ref.
|
|
171
|
+
}, [mode]);
|
|
172
|
+
useEffect(() => () => {
|
|
173
|
+
session.current.mode = null;
|
|
174
|
+
abort.current?.abort();
|
|
175
|
+
stopRecorder.current?.();
|
|
176
|
+
}, []);
|
|
177
|
+
async function ghost(signal) {
|
|
178
|
+
const wait = (ms) => new Promise((resolve, reject) => {
|
|
179
|
+
if (signal.aborted) {
|
|
180
|
+
reject(new Error("stopped"));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const cancel = () => {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
reject(new Error("stopped"));
|
|
186
|
+
};
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
signal.removeEventListener("abort", cancel);
|
|
189
|
+
resolve();
|
|
190
|
+
}, ms);
|
|
191
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
192
|
+
});
|
|
193
|
+
const target = async (selector) => {
|
|
194
|
+
const element = document.querySelector(selector);
|
|
195
|
+
if (!element)
|
|
196
|
+
throw new Error("missing target");
|
|
197
|
+
element.scrollIntoView({ block: "center", behavior: "instant" });
|
|
198
|
+
await wait(300);
|
|
199
|
+
const rect = element.getBoundingClientRect();
|
|
200
|
+
const point = {
|
|
201
|
+
x: rect.left + rect.width / 2,
|
|
202
|
+
y: rect.top + rect.height / 2,
|
|
203
|
+
};
|
|
204
|
+
setCursor(point);
|
|
205
|
+
element.dispatchEvent(new MouseEvent("mousemove", {
|
|
206
|
+
bubbles: true,
|
|
207
|
+
clientX: point.x,
|
|
208
|
+
clientY: point.y,
|
|
209
|
+
}));
|
|
210
|
+
add({
|
|
211
|
+
kind: "move",
|
|
212
|
+
label: "Ghost pointer moved",
|
|
213
|
+
x: Math.round((point.x / innerWidth) * 100),
|
|
214
|
+
y: Math.round((point.y / innerHeight) * 100),
|
|
215
|
+
});
|
|
216
|
+
await wait(700);
|
|
217
|
+
return element;
|
|
218
|
+
};
|
|
219
|
+
const click = (element) => {
|
|
220
|
+
const rect = element.getBoundingClientRect();
|
|
221
|
+
element.dispatchEvent(new MouseEvent("click", {
|
|
222
|
+
bubbles: true,
|
|
223
|
+
cancelable: true,
|
|
224
|
+
view: window,
|
|
225
|
+
clientX: rect.left + rect.width / 2,
|
|
226
|
+
clientY: rect.top + rect.height / 2,
|
|
227
|
+
}));
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
await wait(500);
|
|
231
|
+
click(await target('[data-replay-label="Filter: Forest"]'));
|
|
232
|
+
await wait(900);
|
|
233
|
+
click(await target('[data-replay-label="Filter: Coast"]'));
|
|
234
|
+
await wait(900);
|
|
235
|
+
click(await target('[data-replay-label="Filter: All"]'));
|
|
236
|
+
const input = (await target("#stay-search"));
|
|
237
|
+
input.focus({ preventScroll: true });
|
|
238
|
+
for (const value of ["A", "Ad", "Adi", "Adir", "Adiro", "Adiron"]) {
|
|
239
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, value);
|
|
240
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
241
|
+
await wait(220);
|
|
242
|
+
}
|
|
243
|
+
await wait(900);
|
|
244
|
+
click(await target('[data-replay-label="Open stay: stay-01"]'));
|
|
245
|
+
await wait(1200);
|
|
246
|
+
stop();
|
|
247
|
+
setMessage("Ghost visit complete. Inspect the sequence to see how it reached a stay.");
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
if (!signal.aborted) {
|
|
251
|
+
stop();
|
|
252
|
+
setMessage("The page changed before the ghost finished. You can inspect the captured portion or try again.");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function start(next) {
|
|
257
|
+
stop();
|
|
258
|
+
if (!replayPage(pathname) || (next === "ghost" && pathname !== "/")) {
|
|
259
|
+
setMessage("Return to Find a stay to run the ghost visit.");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
abort.current = controller;
|
|
264
|
+
setStarting(true);
|
|
265
|
+
try {
|
|
266
|
+
const { record } = await import("@rrweb/record");
|
|
267
|
+
if (controller.signal.aborted)
|
|
268
|
+
return;
|
|
269
|
+
captured.current = [];
|
|
270
|
+
setRecording([]);
|
|
271
|
+
let bytes = 0;
|
|
272
|
+
const cleanup = record({
|
|
273
|
+
blockSelector: "#twig-playground, .vac-playground-invite, .vac-replay-stop, .vac-ghost-cursor, input:not(#stay-search), textarea, select",
|
|
274
|
+
maskAllInputs: true,
|
|
275
|
+
maskInputFn: (text, element) => element.id === "stay-search" && !masked ? text : "[masked]",
|
|
276
|
+
recordCanvas: false,
|
|
277
|
+
collectFonts: true,
|
|
278
|
+
sampling: { mousemove: 100, scroll: 200 },
|
|
279
|
+
emit: (event) => {
|
|
280
|
+
if (controller.signal.aborted)
|
|
281
|
+
return;
|
|
282
|
+
bytes += JSON.stringify(event).length;
|
|
283
|
+
if (bytes > 8_000_000 || captured.current.length >= 3000) {
|
|
284
|
+
queueMicrotask(() => {
|
|
285
|
+
stop();
|
|
286
|
+
setMessage("Recording limit reached. Your visit is ready to inspect.");
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
captured.current.push(event);
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
if (!cleanup)
|
|
294
|
+
throw new Error("Recorder unavailable");
|
|
295
|
+
stopRecorder.current = cleanup;
|
|
296
|
+
if (controller.signal.aborted) {
|
|
297
|
+
cleanup();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
setStarting(false);
|
|
303
|
+
setMessage("Could not start the page recorder. Please try again.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
setStarting(false);
|
|
307
|
+
session.current = {
|
|
308
|
+
mode: next,
|
|
309
|
+
start: performance.now(),
|
|
310
|
+
masked,
|
|
311
|
+
count: 0,
|
|
312
|
+
};
|
|
313
|
+
setFrames([]);
|
|
314
|
+
setCapturedMasked(masked);
|
|
315
|
+
setMessage("");
|
|
316
|
+
setMode(next);
|
|
317
|
+
add({
|
|
318
|
+
kind: "page",
|
|
319
|
+
label: pathname === "/" ? "Find a stay" : "Stay details",
|
|
320
|
+
});
|
|
321
|
+
const activeFilter = document.querySelector('#stay-setting-filters [aria-pressed="true"]');
|
|
322
|
+
if (activeFilter?.dataset.replayLabel)
|
|
323
|
+
add({ kind: "state", label: activeFilter.dataset.replayLabel });
|
|
324
|
+
const search = document.querySelector("#stay-search");
|
|
325
|
+
if (search?.value)
|
|
326
|
+
add({
|
|
327
|
+
kind: "input",
|
|
328
|
+
label: "Initial search",
|
|
329
|
+
value: searchValue(search.value, masked),
|
|
330
|
+
});
|
|
331
|
+
if (next === "ghost")
|
|
332
|
+
void ghost(controller.signal);
|
|
333
|
+
}
|
|
334
|
+
return (_jsxs(ReplayContext.Provider, { value: {
|
|
335
|
+
exercise,
|
|
336
|
+
setExercise,
|
|
337
|
+
reset,
|
|
338
|
+
resetCount,
|
|
339
|
+
recording,
|
|
340
|
+
starting,
|
|
341
|
+
frames,
|
|
342
|
+
mode,
|
|
343
|
+
masked,
|
|
344
|
+
setMasked,
|
|
345
|
+
capturedMasked,
|
|
346
|
+
message,
|
|
347
|
+
start,
|
|
348
|
+
stop,
|
|
349
|
+
clear: () => {
|
|
350
|
+
stop();
|
|
351
|
+
captured.current = [];
|
|
352
|
+
setRecording([]);
|
|
353
|
+
setFrames([]);
|
|
354
|
+
setMessage("");
|
|
355
|
+
},
|
|
356
|
+
}, children: [children, mode === "manual" && (_jsxs("div", { className: "vac-replay-stop", children: [_jsx("strong", { children: "Recording your visit" }), _jsx("span", { children: "Local only" }), _jsx("button", { type: "button", onClick: stop, children: "End recording and review" })] })), cursor && (_jsxs("div", { className: "vac-ghost-cursor", style: { left: cursor.x, top: cursor.y }, "aria-hidden": "true", children: [_jsx("svg", { width: "24", height: "30", viewBox: "0 0 24 30", children: _jsx("path", { d: "M2 2v23l6-6 5 9 4-2-5-9h9Z", fill: "var(--ph-neutral-ink)", stroke: "var(--ph-neutral-background)", strokeWidth: "2", strokeLinejoin: "round" }) }), _jsx("span", { children: "Ghost visitor" })] }))] }));
|
|
357
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import "@rrweb/replay/dist/style.css";
|
|
5
|
+
export function SessionPlayer({ events }) {
|
|
6
|
+
const container = useRef(null);
|
|
7
|
+
const controls = useRef(null);
|
|
8
|
+
const [fullscreen, setFullscreen] = useState(false);
|
|
9
|
+
const [fullscreenError, setFullscreenError] = useState("");
|
|
10
|
+
const viewport = useRef(null);
|
|
11
|
+
const root = useRef(null);
|
|
12
|
+
const player = useRef(null);
|
|
13
|
+
const [playing, setPlaying] = useState(false);
|
|
14
|
+
const [position, setPosition] = useState(0);
|
|
15
|
+
const [duration, setDuration] = useState(0);
|
|
16
|
+
const [status, setStatus] = useState("Loading recording…");
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let disposed = false;
|
|
19
|
+
let observer;
|
|
20
|
+
let ticker;
|
|
21
|
+
let instance;
|
|
22
|
+
let onFullscreen;
|
|
23
|
+
void import("@rrweb/replay")
|
|
24
|
+
.then(({ Replayer }) => {
|
|
25
|
+
if (disposed || !root.current || !viewport.current)
|
|
26
|
+
return;
|
|
27
|
+
instance = new Replayer(events, {
|
|
28
|
+
root: root.current,
|
|
29
|
+
mouseTail: false,
|
|
30
|
+
showWarning: false,
|
|
31
|
+
showDebug: false,
|
|
32
|
+
// Preserve layout when hiding overlays so recorded pointer coordinates stay aligned.
|
|
33
|
+
insertStyleRules: [
|
|
34
|
+
".vac-dock, .vac-playground-invite, .vac-replay-stop, .vac-ghost-cursor { visibility: hidden !important; }",
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
player.current = instance;
|
|
38
|
+
const total = instance.getMetaData().totalTime;
|
|
39
|
+
setDuration(total);
|
|
40
|
+
setPosition(0);
|
|
41
|
+
setPlaying(false);
|
|
42
|
+
setStatus("");
|
|
43
|
+
instance.pause(0);
|
|
44
|
+
const resize = () => {
|
|
45
|
+
if (!instance || !viewport.current)
|
|
46
|
+
return;
|
|
47
|
+
const iframe = instance.iframe;
|
|
48
|
+
const width = Number(iframe.getAttribute("width")) || 1280;
|
|
49
|
+
const height = Number(iframe.getAttribute("height")) || 800;
|
|
50
|
+
const scale = document.fullscreenElement === container.current
|
|
51
|
+
? Math.min(viewport.current.clientWidth / width, Math.max(80, window.innerHeight -
|
|
52
|
+
(controls.current?.offsetHeight ?? 140) -
|
|
53
|
+
48) / height)
|
|
54
|
+
: viewport.current.clientWidth / width;
|
|
55
|
+
instance.wrapper.style.transformOrigin = "top left";
|
|
56
|
+
instance.wrapper.style.transform = `scale(${scale})`;
|
|
57
|
+
viewport.current.style.height = `${height * scale}px`;
|
|
58
|
+
};
|
|
59
|
+
onFullscreen = () => {
|
|
60
|
+
setFullscreen(document.fullscreenElement === container.current);
|
|
61
|
+
resize();
|
|
62
|
+
};
|
|
63
|
+
document.addEventListener("fullscreenchange", onFullscreen);
|
|
64
|
+
instance.on("resize", resize);
|
|
65
|
+
observer = new ResizeObserver(resize);
|
|
66
|
+
observer.observe(viewport.current);
|
|
67
|
+
if (controls.current)
|
|
68
|
+
observer.observe(controls.current);
|
|
69
|
+
resize();
|
|
70
|
+
ticker = setInterval(() => {
|
|
71
|
+
if (!instance)
|
|
72
|
+
return;
|
|
73
|
+
const time = Math.min(total, Math.max(0, instance.getCurrentTime()));
|
|
74
|
+
setPosition(time);
|
|
75
|
+
if (time >= total)
|
|
76
|
+
setPlaying(false);
|
|
77
|
+
}, 100);
|
|
78
|
+
})
|
|
79
|
+
.catch(() => {
|
|
80
|
+
if (!disposed)
|
|
81
|
+
setStatus("Could not load this recording. Try recording a new visit.");
|
|
82
|
+
});
|
|
83
|
+
return () => {
|
|
84
|
+
disposed = true;
|
|
85
|
+
if (onFullscreen)
|
|
86
|
+
document.removeEventListener("fullscreenchange", onFullscreen);
|
|
87
|
+
observer?.disconnect();
|
|
88
|
+
if (ticker)
|
|
89
|
+
clearInterval(ticker);
|
|
90
|
+
instance?.destroy();
|
|
91
|
+
player.current = null;
|
|
92
|
+
};
|
|
93
|
+
}, [events]);
|
|
94
|
+
return (_jsxs("div", { ref: container, className: "vac-session-player", children: [status && _jsx("p", { role: "status", children: status }), _jsx("div", { ref: viewport, className: "vac-session-viewport", children: _jsx("div", { ref: root }) }), _jsxs("div", { ref: controls, className: "vac-session-controls", children: [_jsxs("label", { htmlFor: "session-playhead", children: [(position / 1000).toFixed(1), "s / ", (duration / 1000).toFixed(1), "s"] }), _jsx("input", { id: "session-playhead", "aria-label": "Replay position", type: "range", min: 0, max: duration || 1, step: 100, value: position, disabled: !!status, onChange: (event) => {
|
|
95
|
+
const time = Number(event.target.value);
|
|
96
|
+
player.current?.pause(time);
|
|
97
|
+
setPosition(time);
|
|
98
|
+
setPlaying(false);
|
|
99
|
+
} }), _jsx("button", { className: "vac-button", disabled: !!status, onClick: () => {
|
|
100
|
+
if (playing) {
|
|
101
|
+
player.current?.pause();
|
|
102
|
+
setPlaying(false);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
player.current?.play(position >= duration ? 0 : position);
|
|
106
|
+
setPlaying(true);
|
|
107
|
+
}
|
|
108
|
+
}, children: _jsx("span", { className: "vac-os-button-face", children: playing
|
|
109
|
+
? "Pause"
|
|
110
|
+
: position >= duration
|
|
111
|
+
? "Replay visit"
|
|
112
|
+
: "Play visit" }) }), _jsx("button", { className: "vac-text-button", disabled: !!status, onClick: async () => {
|
|
113
|
+
setFullscreenError("");
|
|
114
|
+
try {
|
|
115
|
+
if (document.fullscreenElement === container.current)
|
|
116
|
+
await document.exitFullscreen();
|
|
117
|
+
else
|
|
118
|
+
await container.current?.requestFullscreen();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
setFullscreenError("Full screen is unavailable. You can still play the recording here.");
|
|
122
|
+
}
|
|
123
|
+
}, children: fullscreen ? "Exit full screen" : "View full screen" }), fullscreenError && _jsx("p", { role: "status", children: fullscreenError })] })] }));
|
|
124
|
+
}
|
package/dist/StayCard.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { nightlyPrice, stayLabel } from "./catalog.js";
|
|
3
|
+
export function StayCardContent({ stay, image, linked = false, }) {
|
|
4
|
+
return (_jsxs(_Fragment, { children: [image, _jsxs("div", { className: "vac-card-body", children: [_jsxs("div", { className: "vac-row", children: [_jsx("h3", { children: stayLabel(stay) }), linked && _jsx("span", { "aria-hidden": "true", children: "\u2197" })] }), _jsx("p", { className: "vac-muted", children: stay.location ?? stay.setting }), stay.capacity && (_jsxs("p", { className: "vac-muted", children: [stay.capacity, " guests", stay.bedrooms ? ` · ${stay.bedrooms} bedrooms` : "", stay.bathrooms ? ` · ${stay.bathrooms} baths` : ""] })), _jsxs("p", { children: [_jsx("strong", { children: nightlyPrice(stay) }), " / night", " ", _jsx("span", { className: "vac-muted", children: "USD" })] })] })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type StaySetting } from "./settings.js";
|
|
3
|
+
export { staySettings } from "./settings.js";
|
|
4
|
+
export type { StaySetting } from "./settings.js";
|
|
5
|
+
export type StayFiltersProps = {
|
|
6
|
+
value: StaySetting;
|
|
7
|
+
onChange: (value: StaySetting) => void;
|
|
8
|
+
id?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
buttonClassName?: string;
|
|
11
|
+
legendClassName?: string;
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
export declare function StayFilters({ value, onChange, id, className, buttonClassName, legendClassName, children, }: StayFiltersProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { staySettings } from "./settings.js";
|
|
4
|
+
export { staySettings } from "./settings.js";
|
|
5
|
+
export function StayFilters({ value, onChange, id, className, buttonClassName, legendClassName, children, }) {
|
|
6
|
+
return (_jsxs("fieldset", { id: id, className: className, children: [_jsx("legend", { className: legendClassName, children: "Filter by destination type" }), children, staySettings.map((setting) => (_jsx("button", { "data-replay-label": `Filter: ${setting}`, type: "button", "aria-pressed": value === setting, className: buttonClassName, onClick: () => onChange(setting), children: setting === "All" ? "All stays" : setting }, setting)))] }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type Stay } from "./catalog.js";
|
|
3
|
+
import { type StayLabState, type StayLabAction } from "./stay-lab.js";
|
|
4
|
+
export declare const useStayLab: () => {
|
|
5
|
+
state: StayLabState;
|
|
6
|
+
dispatch: (action: StayLabAction) => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function StayLabProvider({ children, }: {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
export declare function StayLab({ stage, onStage, pathname, renderStayLink, }: {
|
|
12
|
+
stage: number;
|
|
13
|
+
onStage: (value: number) => void;
|
|
14
|
+
pathname: string;
|
|
15
|
+
renderStayLink: (stay: Stay) => ReactNode;
|
|
16
|
+
}): import("react").JSX.Element;
|
package/dist/StayLab.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { LabChoices } from "./LabChoices.js";
|
|
4
|
+
import { FinishLabButton } from "./FinishLabButton.js";
|
|
5
|
+
import { createContext, useContext, useReducer, useState, } from "react";
|
|
6
|
+
import { stays } from "./catalog.js";
|
|
7
|
+
import { initialStayLab, stayLabReducer, stayCode, } from "./stay-lab.js";
|
|
8
|
+
import { LabChecklist } from "./LabChecklist.js";
|
|
9
|
+
const Context = createContext({ state: initialStayLab, dispatch: () => { } });
|
|
10
|
+
export const useStayLab = () => useContext(Context);
|
|
11
|
+
export function StayLabProvider({ children, }) {
|
|
12
|
+
const [state, dispatch] = useReducer(stayLabReducer, initialStayLab);
|
|
13
|
+
return (_jsx(Context.Provider, { value: { state, dispatch }, children: children }));
|
|
14
|
+
}
|
|
15
|
+
function Action({ children, onClick, }) {
|
|
16
|
+
return (_jsx("button", { className: "vac-button", type: "button", onClick: onClick, children: _jsx("span", { className: "vac-os-button-face", children: children }) }));
|
|
17
|
+
}
|
|
18
|
+
export function StayLab({ stage, onStage, pathname, renderStayLink, }) {
|
|
19
|
+
const { state, dispatch } = useStayLab();
|
|
20
|
+
const [identified, setIdentified] = useState(false);
|
|
21
|
+
const [selected, select] = useState(0);
|
|
22
|
+
const repair = state.before.length > 0;
|
|
23
|
+
const distinct = new Set(state.events.map((event) => event.actualStayId));
|
|
24
|
+
const complete = distinct.size >= 2;
|
|
25
|
+
const nextStay = stays.find((stay) => `/stays/${stay.id}` !== pathname && !distinct.has(stay.id)) ?? stays[0];
|
|
26
|
+
if (stage === 0)
|
|
27
|
+
return (_jsxs("div", { className: "vac-guide-intro", children: [_jsx("h3", { children: "Which stay did they view?" }), _jsx("p", { children: "A view event tells you someone opened a listing. To compare listings, it also needs to say which stay they viewed." }), _jsxs("div", { className: "vac-guide-example vac-booking-overview", children: [_jsx("strong", { children: "Open a stay \u2192 Record its view \u2192 Identify the listing" }), _jsx("p", { children: "You\u2019ll visit two stays, then connect their IDs to the event." })] }), _jsx(Action, { onClick: () => onStage(1), children: "Follow a stay view \u2192" })] }));
|
|
28
|
+
if (stage === 1 && (!state.applied || !state.running))
|
|
29
|
+
return (_jsxs("section", { className: "vac-lab vac-builder", children: [_jsx("h3", { children: repair ? "Connect the stay ID" : "Record when a stay opens" }), _jsx("p", { children: repair ? (_jsxs(_Fragment, { children: ["Twig holds the listing\u2019s ID in ", _jsx("code", { children: "stay.id" }), ". Add it as", " ", _jsx("code", { children: "stay_id" }), " so the event identifies the listing."] })) : (_jsxs(_Fragment, { children: ["This code runs when a stay page opens. It records", " ", _jsx("code", { children: "stay_viewed" }), ", but doesn\u2019t include which stay it was."] })) }), repair && (_jsxs("label", { className: "vac-replay-mask", children: [_jsx("input", { type: "checkbox", checked: identified, onChange: (event) => setIdentified(event.target.checked) }), "Include ", _jsx("code", { children: "stay_id" })] })), _jsx("pre", { className: "vac-booking-code", children: stayCode(repair && identified) }), _jsx("button", { className: "vac-button", disabled: repair && !identified, onClick: () => dispatch({ type: "apply", identified: repair && identified }), children: _jsx("span", { className: "vac-os-button-face", children: "Apply the code \u2192" }) })] }));
|
|
30
|
+
if (stage === 1)
|
|
31
|
+
return (_jsxs("section", { className: "vac-lab vac-builder", children: [_jsx("h3", { children: complete ? "Your stay views are ready" : "Visit two stays on Twig" }), _jsx("p", { children: complete ? ("Both pages opened and produced view events. Review what each event can tell you.") : (_jsx(_Fragment, { children: "Code applied. Open another stay below. The event fires when its page opens, not when you hover over its link." })) }), _jsx(LabChecklist, { label: "Stay view progress", items: [
|
|
32
|
+
{ label: "Open the first stay", done: distinct.size >= 1 },
|
|
33
|
+
{ label: "Open a different stay", done: complete },
|
|
34
|
+
] }), state.events.length > 0 && (_jsxs("p", { role: "status", children: ["Last viewed: ", state.events.at(-1)?.actualTitle, "."] })), complete ? (_jsx(Action, { onClick: () => onStage(2), children: "Review the stay views \u2192" })) : (renderStayLink(nextStay)), _jsxs("details", { className: "vac-lab-step", children: [_jsx("summary", { children: "Applied code" }), _jsx("pre", { className: "vac-booking-code", children: stayCode(state.applied === "identified") })] })] }));
|
|
35
|
+
const event = state.events[selected] ?? state.events.at(-1);
|
|
36
|
+
if (!event)
|
|
37
|
+
return (_jsxs("div", { className: "vac-lab", children: [_jsx("h3", { children: "Open a stay first" }), _jsx(Action, { onClick: () => onStage(1), children: "Continue the exercise \u2192" })] }));
|
|
38
|
+
return (_jsxs("section", { className: "vac-lab vac-builder", children: [_jsx("h3", { children: repair
|
|
39
|
+
? "See which listing each event describes"
|
|
40
|
+
: "Two views, no listing IDs" }), _jsx(LabChoices, { label: "Recorded visit", value: state.events.indexOf(event), onChange: select, options: state.events.map((view, index) => ({
|
|
41
|
+
value: index,
|
|
42
|
+
label: (_jsxs(_Fragment, { children: [index + 1, ". ", view.actualTitle, " \u00B7", " ", _jsx("code", { children: view.properties.stay_id ?? "ID not captured" })] })),
|
|
43
|
+
})) }), _jsxs("dl", { className: "vac-event-comparison", children: [_jsxs("div", { children: [_jsx("dt", { children: "Viewed on Twig" }), _jsx("dd", { children: event.actualTitle })] }), _jsxs("div", { children: [_jsxs("dt", { children: ["Captured ", _jsx("code", { children: "stay_id" })] }), _jsx("dd", { children: _jsx("code", { children: event.properties.stay_id ?? "Not captured" }) })] })] }), _jsx("p", { children: repair ? (_jsxs(_Fragment, { children: ["The event identifies the stay using ", _jsx("code", { children: "stay_id" }), ". The event name stays the same across listings."] })) : ("The lab knows which page you opened, but the captured event doesn’t. Both visits produce indistinguishable events.") }), _jsx("pre", { children: JSON.stringify({ event: event.event, properties: event.properties }, null, 2) }), repair ? (_jsxs(_Fragment, { children: [_jsx("h4", { children: "Views by stay" }), _jsx("ul", { className: "vac-booking-funnel", children: [...distinct].map((id) => (_jsxs("li", { children: [_jsx("code", { children: id }), _jsx("strong", { children: state.events.filter((view) => view.properties.stay_id === id).length })] }, id))) }), _jsx("p", { children: "Before: no listing IDs. Now: views grouped by stay. These are views, not unique visitors." }), _jsx(FinishLabButton, { onClick: () => onStage(3) })] })) : (_jsx(Action, { onClick: () => {
|
|
44
|
+
dispatch({ type: "repair" });
|
|
45
|
+
onStage(1);
|
|
46
|
+
}, children: "Add the missing stay ID \u2192" }))] }));
|
|
47
|
+
}
|
package/dist/ai-lab.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** A local teaching model. These records never leave the browser. */
|
|
2
|
+
export type AiConfig = {
|
|
3
|
+
input: boolean;
|
|
4
|
+
output: boolean;
|
|
5
|
+
linked: boolean;
|
|
6
|
+
errors: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type AiScenario = "success" | "timeout";
|
|
9
|
+
export type AiRun = {
|
|
10
|
+
id: number;
|
|
11
|
+
prompt: string;
|
|
12
|
+
response: string | null;
|
|
13
|
+
scenario: AiScenario;
|
|
14
|
+
config: AiConfig;
|
|
15
|
+
traceId: string;
|
|
16
|
+
event: {
|
|
17
|
+
event: "$ai_generation";
|
|
18
|
+
properties: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export type AiState = {
|
|
22
|
+
config: AiConfig;
|
|
23
|
+
applied: AiConfig | null;
|
|
24
|
+
runs: AiRun[];
|
|
25
|
+
awaitingRun: boolean;
|
|
26
|
+
};
|
|
27
|
+
export declare const initialAiState: AiState;
|
|
28
|
+
export type AiAction = {
|
|
29
|
+
type: "edit";
|
|
30
|
+
config: Partial<AiConfig>;
|
|
31
|
+
} | {
|
|
32
|
+
type: "apply";
|
|
33
|
+
} | {
|
|
34
|
+
type: "run";
|
|
35
|
+
prompt: string;
|
|
36
|
+
scenario: AiScenario;
|
|
37
|
+
} | {
|
|
38
|
+
type: "clear";
|
|
39
|
+
} | {
|
|
40
|
+
type: "reset";
|
|
41
|
+
};
|
|
42
|
+
export declare const fixtureRequest = "A forest weekend for four, with a fire pit and lake access.";
|
|
43
|
+
export declare const fixtureResponse = "Try Adiron-shack: an A-frame in the Adirondacks with two king suites, a path to the lake, and a fire pit. Sleeps four, $355 per night.";
|
|
44
|
+
export declare function aiReducer(state: AiState, action: AiAction): AiState;
|
|
45
|
+
export declare function aiChecks(run: AiRun): {
|
|
46
|
+
label: string;
|
|
47
|
+
passed: boolean;
|
|
48
|
+
help: string;
|
|
49
|
+
}[];
|
|
50
|
+
export declare function aiCode(config: AiConfig): string;
|
|
51
|
+
/** Feedback follows captured evidence, not whether every optional field was selected. */
|
|
52
|
+
export declare function recommendationEvidence(run: AiRun): {
|
|
53
|
+
complete: boolean;
|
|
54
|
+
explanation: string;
|
|
55
|
+
};
|