Skip to content

Commit 208da40

Browse files
committed
feat: fix globe
1 parent bde2eb7 commit 208da40

6 files changed

Lines changed: 296 additions & 58 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
"@types/three": "^0.182.0",
111111
"babel-plugin-react-compiler": "^1.0.0",
112112
"eslint": "^9.39.2",
113+
"happy-dom": "^20.8.9",
113114
"tailwind-scrollbar": "^4.0.2",
114115
"tailwindcss": "^4.1.18",
115116
"typescript": "^5.9.3"

apps/web/src/app/(dashboard)/[websiteSlug]/overlays/detail-page-overlay.test.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,11 @@ describe("ContactVisitorDetailView", () => {
332332
);
333333
});
334334

335-
it("omits the globe when the hero visitor has no coordinates", async () => {
335+
it("omits the globe when the hero visitor is missing part of the coordinate pair", async () => {
336336
const html = await renderView({
337337
heroVisitor: {
338338
...heroVisitor,
339339
latitude: null,
340-
longitude: null,
341340
},
342341
});
343342

apps/web/src/app/(dashboard)/[websiteSlug]/overlays/detail-page-overlay.tsx

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useQueryNormalizer } from "@normy/react-query";
77
import { useQueries, useQuery } from "@tanstack/react-query";
88
import { Monitor, Smartphone } from "lucide-react";
99
import { useMemo } from "react";
10-
import { Globe, type GlobeVisitor } from "@/components/globe";
10+
import { Globe, type GlobeFocus, type GlobeVisitor } from "@/components/globe";
1111
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
1212
import { Avatar } from "@/components/ui/avatar";
1313
import {
@@ -37,6 +37,10 @@ type VisitorDetail = NonNullable<
3737
RouterOutputs["conversation"]["getVisitorById"]
3838
>;
3939
type HeroDetails = ReturnType<typeof buildHeroDetails>;
40+
type HeroGlobeData = {
41+
focus: GlobeFocus;
42+
visitor: GlobeVisitor;
43+
};
4044
type DeviceKind = "desktop" | "mobile";
4145
type DeviceDetailById = Record<
4246
string,
@@ -239,25 +243,31 @@ function buildHeroDetails(params: {
239243
};
240244
}
241245

242-
function buildHeroGlobeVisitor(params: {
246+
function buildHeroGlobeData(params: {
243247
hero: HeroDetails;
244248
heroVisitor: VisitorDetail | null;
245-
}): GlobeVisitor | null {
249+
}): HeroGlobeData | null {
246250
const { hero, heroVisitor } = params;
247251

248252
if (heroVisitor?.latitude == null || heroVisitor.longitude == null) {
249253
return null;
250254
}
251255

252256
return {
253-
avatarUrl: hero.avatarUrl,
254-
id: heroVisitor.id,
255-
latitude: heroVisitor.latitude,
256-
locationLabel: hero.locationLabel,
257-
longitude: heroVisitor.longitude,
258-
name: hero.title,
259-
pageLabel:
260-
heroVisitor.currentPage?.path ?? heroVisitor.currentPage?.title ?? null,
257+
focus: {
258+
latitude: heroVisitor.latitude,
259+
longitude: heroVisitor.longitude,
260+
},
261+
visitor: {
262+
avatarUrl: hero.avatarUrl,
263+
id: heroVisitor.id,
264+
latitude: heroVisitor.latitude,
265+
locationLabel: hero.locationLabel,
266+
longitude: heroVisitor.longitude,
267+
name: hero.title,
268+
pageLabel:
269+
heroVisitor.currentPage?.path ?? heroVisitor.currentPage?.title ?? null,
270+
},
261271
};
262272
}
263273

@@ -567,19 +577,11 @@ function DetailPrimaryPanel({
567577
const hasIdentifiers = Boolean(
568578
contact?.email || contact?.externalId || contact?.contactOrganizationId
569579
);
570-
const globeVisitor = buildHeroGlobeVisitor({
580+
const heroGlobeData = buildHeroGlobeData({
571581
hero,
572582
heroVisitor,
573583
});
574584

575-
const globeFocus =
576-
heroVisitor?.latitude != null && heroVisitor.longitude != null
577-
? {
578-
latitude: heroVisitor.latitude,
579-
longitude: heroVisitor.longitude,
580-
}
581-
: null;
582-
583585
return (
584586
<div className="relative h-full border-primary/5 border-b lg:border-r lg:border-b-0">
585587
<ScrollArea
@@ -590,7 +592,7 @@ function DetailPrimaryPanel({
590592
<div
591593
className={cn(
592594
"mx-auto flex w-full max-w-sm flex-col gap-8",
593-
globeVisitor ? "pb-8 lg:pb-64" : undefined
595+
heroGlobeData ? "pb-8 lg:pb-64" : undefined
594596
)}
595597
data-slot="contact-visitor-detail-primary-panel"
596598
>
@@ -669,7 +671,7 @@ function DetailPrimaryPanel({
669671
{visitorInsight}
670672
</p>
671673

672-
{globeVisitor && globeFocus ? (
674+
{heroGlobeData ? (
673675
<div
674676
className="lg:hidden"
675677
data-slot="contact-visitor-detail-mobile-globe-wrapper"
@@ -679,16 +681,16 @@ function DetailPrimaryPanel({
679681
allowDrag={false}
680682
autoRotate={false}
681683
className="min-h-[240px]"
682-
focus={globeFocus}
683-
visitors={[globeVisitor]}
684+
focus={heroGlobeData.focus}
685+
visitors={[heroGlobeData.visitor]}
684686
/>
685687
</div>
686688
</div>
687689
) : null}
688690
</div>
689691
</ScrollArea>
690692

691-
{globeVisitor && globeFocus ? (
693+
{heroGlobeData ? (
692694
<div
693695
className="pointer-events-none absolute inset-x-0 bottom-0 hidden px-8 pb-8 lg:block"
694696
data-slot="contact-visitor-detail-desktop-globe-wrapper"
@@ -699,8 +701,8 @@ function DetailPrimaryPanel({
699701
allowDrag={false}
700702
autoRotate={false}
701703
className="min-h-[240px]"
702-
focus={globeFocus}
703-
visitors={[globeVisitor]}
704+
focus={heroGlobeData.focus}
705+
visitors={[heroGlobeData.visitor]}
704706
/>
705707
</div>
706708
</div>
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2+
import { Window } from "happy-dom";
3+
import type React from "react";
4+
5+
const createGlobeMock = mock(() => ({
6+
destroy: mock(() => {}),
7+
update: mock(() => {}),
8+
}));
9+
10+
mock.module("cobe", () => ({
11+
default: createGlobeMock,
12+
}));
13+
14+
mock.module("next-themes", () => ({
15+
useTheme: () => ({
16+
resolvedTheme: "light",
17+
}),
18+
}));
19+
20+
type RootHandle = {
21+
render(node: React.ReactNode): void;
22+
unmount(): void;
23+
};
24+
25+
let activeRoot: RootHandle | null = null;
26+
let mountNode: HTMLElement | null = null;
27+
let windowInstance: Window | null = null;
28+
let globeSize = { height: 0, width: 0 };
29+
const resizeObserverDisconnectMock = mock(() => {});
30+
const resizeObserverObserveMock = mock((_element: Element) => {});
31+
const installedGlobalKeys = [
32+
"window",
33+
"self",
34+
"document",
35+
"navigator",
36+
"Document",
37+
"DocumentFragment",
38+
"Element",
39+
"Event",
40+
"EventTarget",
41+
"HTMLElement",
42+
"HTMLCanvasElement",
43+
"MutationObserver",
44+
"Node",
45+
"SVGElement",
46+
"Text",
47+
"getComputedStyle",
48+
"ResizeObserver",
49+
"requestAnimationFrame",
50+
"cancelAnimationFrame",
51+
"IS_REACT_ACT_ENVIRONMENT",
52+
] as const;
53+
54+
class MockResizeObserver {
55+
static callback: (() => void) | null = null;
56+
57+
disconnect = resizeObserverDisconnectMock;
58+
observe = resizeObserverObserveMock;
59+
60+
constructor(callback: () => void) {
61+
MockResizeObserver.callback = callback;
62+
}
63+
64+
static trigger() {
65+
MockResizeObserver.callback?.();
66+
}
67+
}
68+
69+
function setGlobalValue(key: string, value: unknown) {
70+
Object.defineProperty(globalThis, key, {
71+
configurable: true,
72+
value,
73+
writable: true,
74+
});
75+
}
76+
77+
function installDomGlobals(window: Window) {
78+
setGlobalValue("window", window);
79+
setGlobalValue("self", window);
80+
setGlobalValue("document", window.document);
81+
setGlobalValue("navigator", window.navigator);
82+
setGlobalValue("Document", window.Document);
83+
setGlobalValue("DocumentFragment", window.DocumentFragment);
84+
setGlobalValue("Element", window.Element);
85+
setGlobalValue("Event", window.Event);
86+
setGlobalValue("EventTarget", window.EventTarget);
87+
setGlobalValue("HTMLElement", window.HTMLElement);
88+
setGlobalValue("HTMLCanvasElement", window.HTMLCanvasElement);
89+
setGlobalValue("MutationObserver", window.MutationObserver);
90+
setGlobalValue("Node", window.Node);
91+
setGlobalValue("SVGElement", window.SVGElement);
92+
setGlobalValue("Text", window.Text);
93+
setGlobalValue("getComputedStyle", window.getComputedStyle.bind(window));
94+
setGlobalValue("ResizeObserver", MockResizeObserver);
95+
setGlobalValue(
96+
"requestAnimationFrame",
97+
mock(() => 1)
98+
);
99+
setGlobalValue(
100+
"cancelAnimationFrame",
101+
mock(() => {})
102+
);
103+
setGlobalValue("IS_REACT_ACT_ENVIRONMENT", true);
104+
105+
Object.defineProperty(window.HTMLElement.prototype, "clientHeight", {
106+
configurable: true,
107+
get() {
108+
return this.getAttribute("data-slot") === "globe-root"
109+
? globeSize.height
110+
: 0;
111+
},
112+
});
113+
114+
Object.defineProperty(window.HTMLElement.prototype, "clientWidth", {
115+
configurable: true,
116+
get() {
117+
return this.getAttribute("data-slot") === "globe-root"
118+
? globeSize.width
119+
: 0;
120+
},
121+
});
122+
}
123+
124+
describe("Globe", () => {
125+
beforeEach(() => {
126+
activeRoot = null;
127+
mountNode = null;
128+
windowInstance = new Window({
129+
url: "https://example.com",
130+
});
131+
globeSize = { height: 0, width: 0 };
132+
createGlobeMock.mockReset();
133+
createGlobeMock.mockImplementation(() => ({
134+
destroy: mock(() => {}),
135+
update: mock(() => {}),
136+
}));
137+
MockResizeObserver.callback = null;
138+
resizeObserverDisconnectMock.mockReset();
139+
resizeObserverObserveMock.mockReset();
140+
installDomGlobals(windowInstance);
141+
});
142+
143+
afterEach(async () => {
144+
const { act } = await import("react");
145+
146+
if (activeRoot) {
147+
await act(async () => {
148+
activeRoot?.unmount();
149+
});
150+
}
151+
152+
mountNode?.remove();
153+
activeRoot = null;
154+
mountNode = null;
155+
windowInstance = null;
156+
157+
for (const key of installedGlobalKeys) {
158+
Reflect.deleteProperty(globalThis, key);
159+
}
160+
});
161+
162+
it("retries globe creation after an initial zero-size mount", async () => {
163+
const { act } = await import("react");
164+
const { createRoot } = await import("react-dom/client");
165+
const { Globe } = await import("./index");
166+
167+
mountNode = document.createElement("div");
168+
document.body.appendChild(mountNode);
169+
activeRoot = createRoot(mountNode);
170+
171+
await act(async () => {
172+
activeRoot?.render(<Globe allowDrag={false} autoRotate={false} />);
173+
});
174+
175+
expect(createGlobeMock).not.toHaveBeenCalled();
176+
expect(document.body.innerHTML).toContain('data-slot="globe-root"');
177+
expect(resizeObserverObserveMock).toHaveBeenCalledTimes(1);
178+
179+
globeSize = { height: 240, width: 320 };
180+
181+
await act(async () => {
182+
MockResizeObserver.trigger();
183+
});
184+
185+
expect(createGlobeMock).toHaveBeenCalledTimes(1);
186+
const createGlobeCalls = createGlobeMock.mock.calls as unknown as [
187+
unknown,
188+
{ height: number; width: number },
189+
][];
190+
191+
expect(createGlobeCalls[0]?.[1]).toMatchObject({
192+
height: 240,
193+
width: 320,
194+
});
195+
196+
await act(async () => {
197+
MockResizeObserver.trigger();
198+
});
199+
200+
expect(createGlobeMock).toHaveBeenCalledTimes(1);
201+
});
202+
});

0 commit comments

Comments
 (0)