Skip to content

Commit f0c1689

Browse files
committed
map overview
1 parent c99b62a commit f0c1689

File tree

7 files changed

+1189
-23
lines changed

7 files changed

+1189
-23
lines changed

apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ const CustomEventsSection = dynamic(() =>
4646
}))
4747
);
4848

49-
type ChartDataPoint = {
49+
const GeoMapSection = dynamic(() =>
50+
import("./overview/_components/geo-map-section").then((mod) => ({
51+
default: mod.GeoMapSection,
52+
}))
53+
);
54+
55+
interface ChartDataPoint {
5056
date: string;
5157
rawDate?: string;
5258
pageviews?: number;
@@ -55,36 +61,36 @@ type ChartDataPoint = {
5561
bounce_rate?: number;
5662
median_session_duration?: number;
5763
[key: string]: unknown;
58-
};
64+
}
5965

60-
type TechnologyData = {
66+
interface TechnologyData {
6167
name: string;
6268
visitors: number;
6369
pageviews?: number;
6470
percentage: number;
6571
icon?: string;
6672
category?: string;
67-
};
73+
}
6874

69-
type CellInfo = {
75+
interface CellInfo {
7076
getValue: () => unknown;
7177
row: { original: unknown };
72-
};
78+
}
7379

74-
type PageRowData = {
80+
interface PageRowData {
7581
name: string;
7682
visitors: number;
7783
pageviews: number;
7884
percentage: number;
79-
};
85+
}
8086

81-
type AnalyticsRowData = {
87+
interface AnalyticsRowData {
8288
name: string;
8389
visitors: number;
8490
pageviews: number;
8591
percentage: number;
8692
referrer?: string;
87-
};
93+
}
8894

8995
const MIN_PREVIOUS_SESSIONS_FOR_TREND = 5;
9096
const MIN_PREVIOUS_VISITORS_FOR_TREND = 5;
@@ -114,6 +120,7 @@ const QUERY_CONFIG = {
114120
"outbound_links",
115121
"outbound_domains",
116122
] as string[],
123+
geo: ["country"] as string[],
117124
},
118125
} as const;
119126

@@ -204,6 +211,13 @@ export function WebsiteOverviewTab({
204211
granularity: dateRange.granularity,
205212
filters,
206213
},
214+
{
215+
id: "overview-geo",
216+
parameters: QUERY_CONFIG.parameters.geo,
217+
limit: QUERY_CONFIG.limit,
218+
granularity: dateRange.granularity,
219+
filters,
220+
},
207221
];
208222

209223
const { isLoading, error, getDataForQuery } = useBatchDynamicQuery(
@@ -244,6 +258,10 @@ export function WebsiteOverviewTab({
244258
getDataForQuery("overview-custom-events", "outbound_domains") || [],
245259
};
246260

261+
const geoData = {
262+
countries: getDataForQuery("overview-geo", "country") || [],
263+
};
264+
247265
const createPercentageCell = () => (info: CellInfo) => {
248266
const percentage = info.getValue() as number;
249267
return <PercentageBadge percentage={percentage} />;
@@ -972,7 +990,7 @@ export function WebsiteOverviewTab({
972990
/>
973991

974992
{/* Technology */}
975-
<div className="grid grid-cols-1 gap-3 sm:gap-4 xl:grid-cols-2 2xl:grid-cols-3">
993+
<div className="grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2">
976994
<DataTable
977995
columns={deviceColumns}
978996
data={analytics.device_types || []}
@@ -1048,6 +1066,8 @@ export function WebsiteOverviewTab({
10481066
]}
10491067
title="Operating Systems"
10501068
/>
1069+
1070+
<GeoMapSection countries={geoData.countries} isLoading={isLoading} />
10511071
</div>
10521072
</div>
10531073
);
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"use client";
2+
3+
import type { LocationData } from "@databuddy/shared/types/website";
4+
import { GlobeIcon } from "@phosphor-icons/react";
5+
import dynamic from "next/dynamic";
6+
import { useMemo } from "react";
7+
import { CountryFlag } from "@/components/icon";
8+
import { Skeleton } from "@/components/ui/skeleton";
9+
10+
const MapComponent = dynamic(
11+
() =>
12+
import("@/components/analytics/map-component").then((mod) => ({
13+
default: mod.MapComponent,
14+
})),
15+
{
16+
loading: () => (
17+
<div className="flex h-full items-center justify-center bg-accent">
18+
<div className="flex flex-col items-center gap-2">
19+
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
20+
<span className="text-muted-foreground text-xs">Loading map…</span>
21+
</div>
22+
</div>
23+
),
24+
ssr: false,
25+
}
26+
);
27+
28+
interface CountryDataItem {
29+
name: string;
30+
country_code?: string;
31+
visitors: number;
32+
pageviews: number;
33+
}
34+
35+
interface GeoMapSectionProps {
36+
countries: CountryDataItem[];
37+
isLoading: boolean;
38+
}
39+
40+
function formatNumber(value: number): string {
41+
if (value == null || Number.isNaN(value)) {
42+
return "0";
43+
}
44+
return Intl.NumberFormat(undefined, {
45+
notation: "compact",
46+
maximumFractionDigits: 1,
47+
}).format(value);
48+
}
49+
50+
export function GeoMapSection({ countries, isLoading }: GeoMapSectionProps) {
51+
const locationData = useMemo<LocationData>(() => {
52+
const processedCountries = (countries || []).map((item) => ({
53+
country: item.name,
54+
country_code: item.country_code || item.name,
55+
visitors: item.visitors,
56+
pageviews: item.pageviews,
57+
}));
58+
return { countries: processedCountries, regions: [] };
59+
}, [countries]);
60+
61+
const topCountries = useMemo(
62+
() =>
63+
locationData.countries
64+
.filter((c) => c.country && c.country.trim() !== "")
65+
.sort((a, b) => b.visitors - a.visitors)
66+
.slice(0, 5),
67+
[locationData.countries]
68+
);
69+
70+
const totalVisitors = useMemo(
71+
() =>
72+
locationData.countries.reduce(
73+
(sum, country) => sum + country.visitors,
74+
0
75+
),
76+
[locationData.countries]
77+
);
78+
79+
if (isLoading) {
80+
return (
81+
<div className="w-full overflow-hidden rounded border bg-accent/50 backdrop-blur-sm">
82+
<div className="px-3 pt-3 pb-2">
83+
<div className="min-w-0 flex-1">
84+
<Skeleton className="h-5 w-32 rounded" />
85+
<Skeleton className="mt-0.5 h-3 w-48 rounded" />
86+
</div>
87+
</div>
88+
<div className="px-3 pb-3">
89+
<Skeleton className="h-[350px] w-full rounded" />
90+
</div>
91+
</div>
92+
);
93+
}
94+
95+
return (
96+
<div className="w-full overflow-hidden rounded border bg-card backdrop-blur-sm">
97+
{/* Toolbar */}
98+
<div className="px-3 pt-3 pb-2">
99+
<div className="flex flex-col items-center justify-between gap-3 sm:flex-row">
100+
<div className="min-w-0 flex-1">
101+
<h3 className="truncate font-semibold text-sidebar-foreground text-sm">
102+
Visitor Locations
103+
</h3>
104+
<p className="mt-0.5 line-clamp-2 text-sidebar-foreground/70 text-xs">
105+
Geographic distribution
106+
</p>
107+
</div>
108+
</div>
109+
</div>
110+
111+
{/* Content */}
112+
<div
113+
className="relative flex flex-col lg:flex-row"
114+
style={{ minHeight: 350 }}
115+
>
116+
<div className="relative min-h-[200px] flex-1 lg:min-h-0">
117+
<MapComponent
118+
height="100%"
119+
isLoading={false}
120+
locationData={locationData}
121+
/>
122+
</div>
123+
124+
<div className="w-full shrink-0 border-t bg-card lg:w-44 lg:border-t-0 lg:border-l">
125+
<div className="h-10 border-b bg-card px-2">
126+
<span className="flex h-full items-center font-semibold text-sidebar-foreground/70 text-xs uppercase tracking-wide">
127+
Top Countries
128+
</span>
129+
</div>
130+
131+
{topCountries.length > 0 ? (
132+
<div className="max-h-48 overflow-y-auto bg-accent lg:max-h-none">
133+
{topCountries.map((country) => {
134+
const percentage =
135+
totalVisitors > 0
136+
? (country.visitors / totalVisitors) * 100
137+
: 0;
138+
const countryCode =
139+
country.country_code?.toUpperCase() ||
140+
country.country.toUpperCase();
141+
142+
return (
143+
<div
144+
className="flex items-center gap-2 border-b px-3 py-2 transition-colors last:border-b-0 hover:bg-accent/80"
145+
key={country.country}
146+
>
147+
<CountryFlag country={countryCode} size="sm" />
148+
<span className="min-w-0 flex-1 truncate text-foreground text-xs">
149+
{country.country}
150+
</span>
151+
<div className="flex shrink-0 items-center gap-1.5 text-right">
152+
<span className="font-medium text-foreground text-xs tabular-nums">
153+
{formatNumber(country.visitors)}
154+
</span>
155+
<span className="text-[10px] text-muted-foreground tabular-nums">
156+
{percentage.toFixed(0)}%
157+
</span>
158+
</div>
159+
</div>
160+
);
161+
})}
162+
</div>
163+
) : (
164+
<div className="flex flex-col items-center justify-center bg-accent p-4 text-center">
165+
<GlobeIcon
166+
className="size-6 text-muted-foreground/30"
167+
weight="duotone"
168+
/>
169+
<p className="mt-2 text-muted-foreground text-xs">
170+
No location data
171+
</p>
172+
</div>
173+
)}
174+
</div>
175+
</div>
176+
</div>
177+
);
178+
}

apps/dashboard/app/globals.css

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,12 +296,10 @@
296296
background-size: 8px 8px;
297297
}
298298

299-
300299
/* ==========================================================================
301300
Root Variables
302301
========================================================================== */
303302

304-
305303
:root {
306304

307305
--background: oklch(0.99 0.005 240);
@@ -310,7 +308,6 @@
310308
--card-foreground: oklch(0.25 0.01 240);
311309
--popover: oklch(1 0 0);
312310
--popover-foreground: oklch(0.25 0.01 240);
313-
314311

315312
--primary: oklch(0.4722 0.2664 270);
316313
--primary-foreground: oklch(1 0 0);
@@ -358,7 +355,7 @@
358355
--font-mono:
359356
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
360357
"Courier New", monospace;
361-
358+
362359
/* Font stack for kbd elements with better symbol support */
363360
--font-kbd:
364361
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
@@ -462,7 +459,7 @@
462459
--font-mono:
463460
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
464461
"Courier New", monospace;
465-
462+
466463
/* Font stack for kbd elements with better symbol support */
467464
--font-kbd:
468465
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
@@ -495,7 +492,6 @@
495492
/* ==========================================================================
496493
Base Styles & Reset
497494
========================================================================== */
498-
499495

500496
@layer base {
501497
* {
@@ -567,6 +563,12 @@
567563
[role="button"] {
568564
cursor: pointer !important;
569565
}
566+
.maplibregl-popup-content {
567+
@apply bg-transparent! shadow-none! p-0! rounded-none!;
568+
}
569+
.maplibregl-popup-tip {
570+
@apply hidden!;
571+
}
570572

571573
}
572574

@@ -724,4 +726,4 @@
724726
.sidebar-backdrop {
725727
background-color: rgba(0, 0, 0, 0.8);
726728
}
727-
}
729+
}

0 commit comments

Comments
 (0)