world map

This commit is contained in:
Fred KISSIE
2025-11-20 08:19:11 +01:00
parent d6e8eb5307
commit 5d1f81a92c
13 changed files with 2643 additions and 192 deletions

View File

@@ -11,7 +11,6 @@ import { useTranslations } from "next-intl";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,

View File

@@ -7,209 +7,220 @@ import { Calendar } from "@app/components/ui/calendar";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ChangeEvent, useEffect, useState } from "react";
export interface DateTimeValue {
date?: Date;
time?: string;
date?: Date;
time?: string;
}
export interface DateTimePickerProps {
label?: string;
value?: DateTimeValue;
onChange?: (value: DateTimeValue) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
showTime?: boolean;
label?: string;
value?: DateTimeValue;
onChange?: (value: DateTimeValue) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
showTime?: boolean;
}
export function DateTimePicker({
label,
value,
onChange,
placeholder = "Select date & time",
className,
disabled = false,
showTime = true,
label,
value,
onChange,
placeholder = "Select date & time",
className,
disabled = false,
showTime = true
}: DateTimePickerProps) {
const [open, setOpen] = useState(false);
const [internalDate, setInternalDate] = useState<Date | undefined>(value?.date);
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
const [open, setOpen] = useState(false);
const [internalDate, setInternalDate] = useState<Date | undefined>(
value?.date
);
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
// Sync internal state with external value prop
useEffect(() => {
setInternalDate(value?.date);
setInternalTime(value?.time || "");
}, [value?.date, value?.time]);
// Sync internal state with external value prop
useEffect(() => {
setInternalDate(value?.date);
setInternalTime(value?.time || "");
}, [value?.date, value?.time]);
const handleDateChange = (date: Date | undefined) => {
setInternalDate(date);
const newValue = { date, time: internalTime };
onChange?.(newValue);
};
const handleDateChange = (date: Date | undefined) => {
setInternalDate(date);
const newValue = { date, time: internalTime };
onChange?.(newValue);
};
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
const time = event.target.value;
setInternalTime(time);
const newValue = { date: internalDate, time };
onChange?.(newValue);
};
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
const time = event.target.value;
setInternalTime(time);
const newValue = { date: internalDate, time };
onChange?.(newValue);
};
const getDisplayText = () => {
if (!internalDate) return placeholder;
const dateStr = internalDate.toLocaleDateString();
if (!showTime || !internalTime) return dateStr;
// Parse time and format in local timezone
const [hours, minutes, seconds] = internalTime.split(':');
const timeDate = new Date();
timeDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), parseInt(seconds || '0', 10));
const timeStr = timeDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `${dateStr} ${timeStr}`;
};
const getDisplayText = () => {
if (!internalDate) return placeholder;
const hasValue = internalDate || (showTime && internalTime);
const dateStr = internalDate.toLocaleDateString();
if (!showTime || !internalTime) return dateStr;
return (
<div className={cn("flex gap-4", className)}>
<div className="flex flex-col gap-2">
{label && (
<Label htmlFor="date-picker">
{label}
</Label>
)}
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date-picker"
disabled={disabled}
className={cn(
"justify-between font-normal",
showTime ? "w-48" : "w-32",
!hasValue && "text-muted-foreground"
)}
>
{getDisplayText()}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
{showTime ? (
<div className="flex">
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
if (!showTime) {
setOpen(false);
}
}}
className="flex-grow w-[250px]"
/>
<div className="p-3 border-l">
<div className="flex flex-col gap-3">
<Label htmlFor="time-input" className="text-sm font-medium">
Time
</Label>
<Input
id="time-input"
type="time"
step="1"
value={internalTime}
onChange={handleTimeChange}
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
// Parse time and format in local timezone
const [hours, minutes, seconds] = internalTime.split(":");
const timeDate = new Date();
timeDate.setHours(
parseInt(hours, 10),
parseInt(minutes, 10),
parseInt(seconds || "0", 10)
);
const timeStr = timeDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit"
});
return `${dateStr} ${timeStr}`;
};
const hasValue = internalDate || (showTime && internalTime);
return (
<div className={cn("flex gap-4", className)}>
<div className="flex flex-col gap-2">
{label && <Label htmlFor="date-picker">{label}</Label>}
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date-picker"
disabled={disabled}
className={cn(
"justify-between font-normal",
showTime ? "w-48" : "w-32",
!hasValue && "text-muted-foreground"
)}
>
{getDisplayText()}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto overflow-hidden p-0"
align="start"
>
{showTime ? (
<div className="flex">
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
if (!showTime) {
setOpen(false);
}
}}
className="grow w-[250px]"
/>
<div className="p-3 border-l">
<div className="flex flex-col gap-3">
<Label
htmlFor="time-input"
className="text-sm font-medium"
>
Time
</Label>
<Input
id="time-input"
type="time"
step="1"
value={internalTime}
onChange={handleTimeChange}
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
) : (
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
setOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
</div>
) : (
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
setOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
);
}
export interface DateRangePickerProps {
startLabel?: string;
endLabel?: string;
startValue?: DateTimeValue;
endValue?: DateTimeValue;
onStartChange?: (value: DateTimeValue) => void;
onEndChange?: (value: DateTimeValue) => void;
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
className?: string;
disabled?: boolean;
showTime?: boolean;
startLabel?: string;
endLabel?: string;
startValue?: DateTimeValue;
endValue?: DateTimeValue;
onStartChange?: (value: DateTimeValue) => void;
onEndChange?: (value: DateTimeValue) => void;
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
className?: string;
disabled?: boolean;
showTime?: boolean;
}
export function DateRangePicker({
// startLabel = "From",
// endLabel = "To",
startValue,
endValue,
onStartChange,
onEndChange,
onRangeChange,
className,
disabled = false,
showTime = true,
// startLabel = "From",
// endLabel = "To",
startValue,
endValue,
onStartChange,
onEndChange,
onRangeChange,
className,
disabled = false,
showTime = true
}: DateRangePickerProps) {
const handleStartChange = (value: DateTimeValue) => {
onStartChange?.(value);
if (onRangeChange && endValue) {
onRangeChange(value, endValue);
}
};
const handleStartChange = (value: DateTimeValue) => {
onStartChange?.(value);
if (onRangeChange && endValue) {
onRangeChange(value, endValue);
}
};
const handleEndChange = (value: DateTimeValue) => {
onEndChange?.(value);
if (onRangeChange && startValue) {
onRangeChange(startValue, value);
}
};
const handleEndChange = (value: DateTimeValue) => {
onEndChange?.(value);
if (onRangeChange && startValue) {
onRangeChange(startValue, value);
}
};
return (
<div className={cn("flex gap-4 items-center", className)}>
<DateTimePicker
label="Start"
value={startValue}
onChange={handleStartChange}
placeholder="Start date & time"
disabled={disabled}
showTime={showTime}
/>
<DateTimePicker
label="End"
value={endValue}
onChange={handleEndChange}
placeholder="End date & time"
disabled={disabled}
showTime={showTime}
/>
</div>
);
}
return (
<div className={cn("flex gap-4 items-center", className)}>
<DateTimePicker
label="Start"
value={startValue}
onChange={handleStartChange}
placeholder="Start date & time"
disabled={disabled}
showTime={showTime}
/>
<DateTimePicker
label="End"
value={endValue}
onChange={handleEndChange}
placeholder="End date & time"
disabled={disabled}
showTime={showTime}
/>
</div>
);
}

View File

@@ -11,7 +11,7 @@ export function InfoSections({
}) {
return (
<div
className={`grid md:grid-cols-[var(--columns)] md:gap-4 gap-2 md:items-start grid-cols-1`}
className={`grid md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start grid-cols-1`}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value

View File

@@ -0,0 +1,288 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import {
logAnalyticsFiltersSchema,
logQueries,
resourceQueries
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Card, CardContent, CardHeader } from "./ui/card";
import { RefreshCw, XIcon } from "lucide-react";
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
import { Button } from "./ui/button";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { Label } from "./ui/label";
import { Separator } from "./ui/separator";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "./InfoSection";
import { WorldMap } from "./WorldMap";
export type AnalyticsContentProps = {
orgId: string;
};
export function LogAnalyticsData(props: AnalyticsContentProps) {
const searchParams = useSearchParams();
const path = usePathname();
const t = useTranslations();
const filters = logAnalyticsFiltersSchema.parse(
Object.fromEntries(searchParams.entries())
);
const isEmptySearchParams =
!filters.resourceId && !filters.timeStart && !filters.timeEnd;
const env = useEnvContext();
const [api] = useState(() => createApiClient(env));
const router = useRouter();
const dateRange = {
startDate: filters.timeStart ? new Date(filters.timeStart) : undefined,
endDate: filters.timeEnd ? new Date(filters.timeEnd) : undefined
};
const { data: resources = [], isFetching: isFetchingResources } = useQuery(
resourceQueries.listNamesPerOrg(props.orgId, api)
);
const {
data: stats,
isFetching: isFetchingAnalytics,
refetch: refreshAnalytics
} = useQuery(
logQueries.requestAnalytics({
orgId: props.orgId,
api,
filters
})
);
const percentBlocked = stats
? new Intl.NumberFormat(navigator.language, {
maximumFractionDigits: 5
}).format(stats.totalBlocked / stats.totalRequests)
: null;
const totalRequests = stats
? new Intl.NumberFormat(navigator.language, {
maximumFractionDigits: 0
}).format(stats.totalRequests)
: null;
function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) {
const newSearch = new URLSearchParams(searchParams);
const timeRegex =
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\d{1,2}))?$/;
if (start.date) {
const startDate = new Date(start.date);
if (start.time) {
const time = timeRegex.exec(start.time);
const groups = time?.groups ?? {};
startDate.setHours(Number(groups.hours));
startDate.setMinutes(Number(groups.minutes));
if (groups.seconds) {
startDate.setSeconds(Number(groups.seconds));
}
}
newSearch.set("timeStart", startDate.toISOString());
}
if (end.date) {
const endDate = new Date(end.date);
if (end.time) {
const time = timeRegex.exec(end.time);
const groups = time?.groups ?? {};
endDate.setHours(Number(groups.hours));
endDate.setMinutes(Number(groups.minutes));
if (groups.seconds) {
endDate.setSeconds(Number(groups.seconds));
}
}
console.log({
endDate
});
newSearch.set("timeEnd", endDate.toISOString());
}
router.replace(`${path}?${newSearch.toString()}`);
}
function getDateTime(date: Date) {
return `${date.getHours()}:${date.getMinutes()}`;
}
return (
<div className="flex flex-col gap-5">
<Card className="">
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-start lg:items-end sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-col lg:flex-row items-start lg:items-end w-full sm:mr-2 gap-2">
<DateRangePicker
startValue={{
date: dateRange.startDate,
time: dateRange.startDate
? getDateTime(dateRange.startDate)
: undefined
}}
endValue={{
date: dateRange.endDate,
time: dateRange.endDate
? getDateTime(dateRange.endDate)
: undefined
}}
onRangeChange={handleTimeRangeUpdate}
className="flex-wrap gap-2"
/>
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
<div className="flex items-end gap-2">
<div className="flex flex-col items-start gap-2 w-48">
<Label htmlFor="resourceId">
{t("filterByResource")}
</Label>
<Select
onValueChange={(newValue) => {
const newSearch = new URLSearchParams(
searchParams
);
newSearch.set("resourceId", newValue);
router.replace(
`${path}?${newSearch.toString()}`
);
}}
value={filters.resourceId?.toString()}
>
<SelectTrigger
id="resourceId"
className="w-full"
>
<SelectValue
placeholder={t("selectResource")}
/>
</SelectTrigger>
<SelectContent className="w-full">
{resources.map((resource) => (
<SelectItem
key={resource.resourceId}
value={resource.resourceId.toString()}
>
{resource.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!isEmptySearchParams && (
<Button
variant="ghost"
onClick={() => {
router.replace(path);
}}
className="gap-2"
>
<XIcon className="size-4" />
{t("resetFilters")}
</Button>
)}
</div>
</div>
<div className="flex items-start gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => refreshAnalytics()}
disabled={isFetchingAnalytics}
className=" relative top-6 lg:static gap-2"
>
<RefreshCw
className={cn(
"size-4",
isFetchingAnalytics && "animate-spin"
)}
/>
{t("refresh")}
</Button>
</div>
</CardHeader>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
{t("totalRequests")}
</InfoSectionTitle>
<InfoSectionContent>
{totalRequests ?? "--"}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
{t("totalBlocked")}
</InfoSectionTitle>
<InfoSectionContent>
<span>{stats?.totalBlocked ?? "--"}</span>
&nbsp;(
<span>{percentBlocked ?? "--"}</span>
<span className="text-muted-foreground">%</span>
)
</InfoSectionContent>
</InfoSection>
</InfoSections>
</CardHeader>
</Card>
<div className="flex flex-col lg:flex-row items-stretch gap-5">
<Card className="w-full">
<CardHeader className="flex flex-col gap-4">
<h3 className="font-medium">
{t("requestsByCountry")}
</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<WorldMap
data={
stats?.requestsPerCountry.map((item) => ({
count: item.total,
code: item.country_code ?? "US"
})) ?? []
}
label={{
singular: "request",
plural: "requests"
}}
/>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-col gap-4">
<h3 className="font-medium">{t("topCountries")}</h3>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* ... */}
</CardContent>
</Card>
</div>
</div>
);
}

281
src/components/WorldMap.tsx Normal file
View File

@@ -0,0 +1,281 @@
/**
* Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx
*/
import { cn } from "@app/lib/cn";
import worldJson from "visionscarto-world-atlas/world/110m.json";
import * as topojson from "topojson-client";
import * as d3 from "d3";
import { useRef, type ComponentRef, useState, useEffect, useMemo } from "react";
import { useTheme } from "next-themes";
import { COUNTRY_CODE_LIST } from "@app/lib/countryCodeList";
import { useTranslations } from "next-intl";
type CountryData = {
alpha_3: string;
name: string;
count: number;
code: string;
};
export type WorldMapProps = {
data: Pick<CountryData, "code" | "count">[];
label: {
singular: string;
plural: string;
};
};
export function WorldMap({ data, label }: WorldMapProps) {
const svgRef = useRef<ComponentRef<"svg">>(null);
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
hoveredCountryAlpha3Code: string | null;
}>({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
const { theme, systemTheme } = useTheme();
const t = useTranslations();
useEffect(() => {
if (!svgRef.current) return;
const svg = drawInteractiveCountries(svgRef.current, setTooltip);
return () => {
svg.selectAll("*").remove();
};
}, []);
const displayNames = new Intl.DisplayNames(navigator.language, {
type: "region",
fallback: "code"
});
const maxValue = Math.max(...data.map((item) => item.count));
const dataByCountryCode = useMemo(() => {
const byCountryCode = new Map<string, CountryData>();
for (const country of data) {
const countryISOData = COUNTRY_CODE_LIST[country.code];
if (countryISOData) {
byCountryCode.set(countryISOData.alpha3, {
...country,
name: displayNames.of(country.code)!,
alpha_3: countryISOData.alpha3
});
}
}
return byCountryCode;
}, [data]);
useEffect(() => {
if (svgRef.current) {
const palette =
colorScales[theme ?? "light"] ??
colorScales[systemTheme ?? "light"];
const getColorForValue = d3
.scaleLinear<string>()
.domain([0, maxValue])
.range(palette);
colorInCountriesWithValues(
svgRef.current,
getColorForValue,
dataByCountryCode
).on("click", (_event, countryPath) => {
console.log({
_event,
countryPath
});
// onCountryClick(countryPath as unknown as WorldJsonCountryData);
});
}
}, [theme, systemTheme, maxValue, dataByCountryCode]);
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
: undefined;
return (
<div className="mx-auto mt-4 w-full relative">
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
className="w-full"
/>
{!!hoveredCountryData && (
<MapTooltip
x={tooltip.x}
y={tooltip.y}
name={hoveredCountryData.name}
value={Intl.NumberFormat(navigator.language).format(
hoveredCountryData.count
)}
label={
hoveredCountryData.count === 1
? t(label.singular)
: t(label.plural)
}
/>
)}
</div>
);
}
interface MapTooltipProps {
name: string;
value: string;
label: string;
x: number;
y: number;
}
function MapTooltip({ name, value, label, x, y }: MapTooltipProps) {
return (
<div
className={cn(
"absolute z-50 p-2 translate-x-2 translate-y-2",
"pointer-events-none rounded-sm",
"bg-white dark:bg-popover shadow border border-border"
)}
style={{
left: x,
top: y
}}
>
<div className="font-semibold">{name}</div>
<strong className="text-primary">{value}</strong> {label}
</div>
);
}
const width = 475;
const height = 335;
const sharedCountryClass = cn("transition-colors");
const colorScales: Record<string, [string, string]> = {
dark: ["#4F4444", "#f36117"],
light: ["#FFF5F3", "#f36117"]
};
const countryClass = cn(
sharedCountryClass,
"stroke-1",
"fill-[#fafafa]",
"stroke-[#dae1e7]",
"dark:fill-[#323236]",
"dark:stroke-[#18181b]"
);
const highlightedCountryClass = cn(
sharedCountryClass,
"stroke-2",
"fill-[#f4f4f5]",
"stroke-[#f36117]",
"dark:fill-[#3f3f46]"
);
function setupProjetionPath() {
const projection = d3
.geoMercator()
.scale(75)
.translate([width / 2, height / 1.5]);
const path = d3.geoPath().projection(projection);
return path;
}
/** @returns the d3 selected svg element */
function drawInteractiveCountries(
element: SVGSVGElement,
setTooltip: React.Dispatch<
React.SetStateAction<{
x: number;
y: number;
hoveredCountryAlpha3Code: string | null;
}>
>
) {
const path = setupProjetionPath();
const data = parseWorldTopoJsonToGeoJsonFeatures();
const svg = d3.select(element);
svg.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("class", countryClass)
.attr("d", path as never)
.on("mouseover", function (event, country) {
const [x, y] = d3.pointer(event, svg.node()?.parentNode);
setTooltip({
x,
y,
hoveredCountryAlpha3Code: country.properties.a3
});
// brings country to front
this.parentNode?.appendChild(this);
d3.select(this).attr("class", highlightedCountryClass);
})
.on("mousemove", function (event) {
const [x, y] = d3.pointer(event, svg.node()?.parentNode);
setTooltip((currentState) => ({ ...currentState, x, y }));
})
.on("mouseout", function () {
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
d3.select(this).attr("class", countryClass);
});
return svg;
}
type WorldJsonCountryData = { properties: { name: string; a3: string } };
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
const collection = topojson.feature(
// @ts-expect-error strings in worldJson not recongizable as the enum values declared in library
worldJson,
worldJson.objects.countries
);
// @ts-expect-error topojson.feature return type incorrectly inferred as not a collection
return collection.features;
}
/**
* Used to color the countries
* @returns the svg elements represeting countries
*/
function colorInCountriesWithValues(
element: SVGSVGElement,
getColorForValue: d3.ScaleLinear<string, string, never>,
dataByCountryCode: Map<string, CountryData>
) {
function getCountryByCountryPath(countryPath: unknown) {
return dataByCountryCode.get(
(countryPath as unknown as WorldJsonCountryData).properties.a3
);
}
const svg = d3.select(element);
return svg
.selectAll("path")
.style("fill", (countryPath) => {
const country = getCountryByCountryPath(countryPath);
if (!country?.count) {
return null;
}
return getColorForValue(country.count);
})
.style("cursor", (countryPath) => {
const country = getCountryByCountryPath(countryPath);
if (!country?.count) {
return null;
}
return "pointer";
});
}