refactor: apply React best practices and migrate to ESLint CLI

- Dynamic import for WordCloud component (code splitting)
- Fix waterfall in handleRefresh with Promise.all
- Memoize data prep functions in overview page (7 useMemo hooks)
- Replace useState+useEffect with useMemo in CountryDisplay/LanguageDisplay
- Memoize navigationCards and class getters in dashboard page
- Extract inline handlers with useCallback
- Fix missing index parameter in TopQuestionsChart map
- Migrate from next lint to ESLint CLI (Next.js 16 deprecation)
This commit is contained in:
2026-01-20 07:05:27 +01:00
parent 5042a6c016
commit 8b3846539f
7 changed files with 298 additions and 288 deletions

View File

@@ -14,9 +14,10 @@ import {
Users, Users,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { useCallback, useEffect, useId, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -35,8 +36,17 @@ import GeographicMap from "../../../components/GeographicMap";
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution"; import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
import TopQuestionsChart from "../../../components/TopQuestionsChart"; import TopQuestionsChart from "../../../components/TopQuestionsChart";
import MetricCard from "../../../components/ui/metric-card"; import MetricCard from "../../../components/ui/metric-card";
import WordCloud from "../../../components/WordCloud"; import type { Company, MetricsResult } from "../../../lib/types";
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
// Dynamic import for heavy D3-based WordCloud component (~50KB)
const WordCloud = dynamic(() => import("../../../components/WordCloud"), {
ssr: false,
loading: () => (
<div className="h-[300px] flex items-center justify-center">
<Skeleton className="h-full w-full" />
</div>
),
});
// Safely wrapped component with useSession // Safely wrapped component with useSession
function DashboardContent() { function DashboardContent() {
@@ -93,35 +103,123 @@ function DashboardContent() {
} }
}, [status, router, isInitialLoad, fetchMetrics]); }, [status, router, isInitialLoad, fetchMetrics]);
async function handleRefresh() { const handleRefresh = useCallback(async () => {
if (isAuditor) return; if (isAuditor) return;
try {
setRefreshing(true);
if (!company?.id) { if (!company?.id) {
setRefreshing(false);
alert("Cannot refresh: Company ID is missing"); alert("Cannot refresh: Company ID is missing");
return; return;
} }
const res = await fetch("/api/admin/refresh-sessions", { setRefreshing(true);
try {
// Start both requests in parallel - metrics will be ready when refresh completes
const [refreshRes, metricsRes] = await Promise.all([
fetch("/api/admin/refresh-sessions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ companyId: company.id }), body: JSON.stringify({ companyId: company.id }),
}); }),
fetch("/api/dashboard/metrics"),
]);
if (res.ok) { if (refreshRes.ok) {
const metricsRes = await fetch("/api/dashboard/metrics");
const data = await metricsRes.json(); const data = await metricsRes.json();
setMetrics(data.metrics); setMetrics(data.metrics);
} else { } else {
const errorData = await res.json(); const errorData = await refreshRes.json();
alert(`Failed to refresh sessions: ${errorData.error}`); alert(`Failed to refresh sessions: ${errorData.error}`);
} }
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
}, [isAuditor, company?.id]);
// Memoized data preparation - must be before any early returns (Rules of Hooks)
const sentimentData = useMemo(() => {
if (!metrics) return [];
return [
{
name: "Positive",
value: metrics.sentimentPositiveCount ?? 0,
color: "hsl(var(--chart-1))",
},
{
name: "Neutral",
value: metrics.sentimentNeutralCount ?? 0,
color: "hsl(var(--chart-2))",
},
{
name: "Negative",
value: metrics.sentimentNegativeCount ?? 0,
color: "hsl(var(--chart-3))",
},
];
}, [metrics]);
const sessionsOverTimeData = useMemo(() => {
if (!metrics?.days) return [];
return Object.entries(metrics.days).map(([date, value]) => ({
date: new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
value: value as number,
}));
}, [metrics?.days]);
const categoriesData = useMemo(() => {
if (!metrics?.categories) return [];
return Object.entries(metrics.categories).map(([name, value]) => {
const formattedName = formatEnumValue(name) || name;
return {
name:
formattedName.length > 15
? `${formattedName.substring(0, 15)}...`
: formattedName,
value: value as number,
};
});
}, [metrics?.categories]);
const languagesData = useMemo(() => {
if (!metrics?.languages) return [];
return Object.entries(metrics.languages).map(([name, value]) => ({
name,
value: value as number,
}));
}, [metrics?.languages]);
const wordCloudData = useMemo(
() => metrics?.wordCloudData ?? [],
[metrics?.wordCloudData]
);
const countryData = useMemo(() => {
if (!metrics?.countries) return {};
return Object.entries(metrics.countries).reduce(
(acc, [code, count]) => {
if (code && count) {
acc[code] = count;
} }
return acc;
},
{} as Record<string, number>
);
}, [metrics?.countries]);
// Use seeded random for stable response time distribution
const responseTimeData = useMemo(() => {
const avgTime = metrics?.avgResponseTime || 1.5;
const seed = avgTime * 1000;
const seededRandom = (i: number) => {
const x = Math.sin(seed + i) * 10000;
return x - Math.floor(x);
};
return Array.from(
{ length: 50 },
(_, i) => avgTime * (0.5 + seededRandom(i))
);
}, [metrics?.avgResponseTime]);
// Show loading state while session status is being determined // Show loading state while session status is being determined
if (status === "loading") { if (status === "loading") {
@@ -211,101 +309,6 @@ function DashboardContent() {
); );
} }
// Data preparation functions
const getSentimentData = () => {
if (!metrics) return [];
const sentimentData = {
positive: metrics.sentimentPositiveCount ?? 0,
neutral: metrics.sentimentNeutralCount ?? 0,
negative: metrics.sentimentNegativeCount ?? 0,
};
return [
{
name: "Positive",
value: sentimentData.positive,
color: "hsl(var(--chart-1))",
},
{
name: "Neutral",
value: sentimentData.neutral,
color: "hsl(var(--chart-2))",
},
{
name: "Negative",
value: sentimentData.negative,
color: "hsl(var(--chart-3))",
},
];
};
const getSessionsOverTimeData = () => {
if (!metrics?.days) return [];
return Object.entries(metrics.days).map(([date, value]) => ({
date: new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
value: value as number,
}));
};
const getCategoriesData = () => {
if (!metrics?.categories) return [];
return Object.entries(metrics.categories).map(([name, value]) => {
const formattedName = formatEnumValue(name) || name;
return {
name:
formattedName.length > 15
? `${formattedName.substring(0, 15)}...`
: formattedName,
value: value as number,
};
});
};
const getLanguagesData = () => {
if (!metrics?.languages) return [];
return Object.entries(metrics.languages).map(([name, value]) => ({
name,
value: value as number,
}));
};
const getWordCloudData = (): WordCloudWord[] => {
if (!metrics?.wordCloudData) return [];
return metrics.wordCloudData;
};
const getCountryData = () => {
if (!metrics?.countries) return {};
return Object.entries(metrics.countries).reduce(
(acc, [code, count]) => {
if (code && count) {
acc[code] = count;
}
return acc;
},
{} as Record<string, number>
);
};
const getResponseTimeData = () => {
const avgTime = metrics.avgResponseTime || 1.5;
const simulatedData: number[] = [];
for (let i = 0; i < 50; i++) {
const randomFactor = 0.5 + Math.random();
simulatedData.push(avgTime * randomFactor);
}
return simulatedData;
};
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Modern Header */} {/* Modern Header */}
@@ -475,14 +478,14 @@ function DashboardContent() {
{/* Charts Section */} {/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ModernLineChart <ModernLineChart
data={getSessionsOverTimeData()} data={sessionsOverTimeData}
title="Sessions Over Time" title="Sessions Over Time"
className="lg:col-span-2" className="lg:col-span-2"
height={350} height={350}
/> />
<ModernDonutChart <ModernDonutChart
data={getSentimentData()} data={sentimentData}
title="Conversation Sentiment" title="Conversation Sentiment"
centerText={{ centerText={{
title: "Total", title: "Total",
@@ -494,13 +497,13 @@ function DashboardContent() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ModernBarChart <ModernBarChart
data={getCategoriesData()} data={categoriesData}
title="Sessions by Category" title="Sessions by Category"
height={350} height={350}
/> />
<ModernDonutChart <ModernDonutChart
data={getLanguagesData()} data={languagesData}
title="Languages Used" title="Languages Used"
height={350} height={350}
/> />
@@ -516,7 +519,7 @@ function DashboardContent() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<GeographicMap countries={getCountryData()} /> <GeographicMap countries={countryData} />
</CardContent> </CardContent>
</Card> </Card>
@@ -529,7 +532,7 @@ function DashboardContent() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <div className="h-[300px]">
<WordCloud words={getWordCloudData()} width={500} height={300} /> <WordCloud words={wordCloudData} width={500} height={300} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -545,7 +548,7 @@ function DashboardContent() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ResponseTimeDistribution <ResponseTimeDistribution
data={getResponseTimeData()} data={responseTimeData}
average={metrics.avgResponseTime || 0} average={metrics.avgResponseTime || 0}
/> />
</CardContent> </CardContent>

View File

@@ -12,7 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { type FC, useEffect, useState } from "react"; import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -31,23 +31,9 @@ const DashboardPage: FC = () => {
} }
}, [status, router]); }, [status, router]);
if (loading) { // Memoize navigation cards - only recalculates when user role changes
return ( const navigationCards = useMemo(() => {
<div className="flex items-center justify-center min-h-[60vh]"> const baseCards = [
<div className="text-center space-y-4">
<div className="relative">
<div className="animate-spin rounded-full h-12 w-12 border-2 border-muted border-t-primary mx-auto" />
<div className="absolute inset-0 animate-ping rounded-full h-12 w-12 border border-primary opacity-20 mx-auto" />
</div>
<p className="text-lg text-muted-foreground animate-pulse">
Loading dashboard...
</p>
</div>
</div>
);
}
const navigationCards = [
{ {
title: "Analytics Overview", title: "Analytics Overview",
description: description:
@@ -56,6 +42,7 @@ const DashboardPage: FC = () => {
href: "/dashboard/overview", href: "/dashboard/overview",
variant: "primary" as const, variant: "primary" as const,
features: ["Real-time metrics", "Interactive charts", "Trend analysis"], features: ["Real-time metrics", "Interactive charts", "Trend analysis"],
adminOnly: false,
}, },
{ {
title: "Session Browser", title: "Session Browser",
@@ -65,9 +52,13 @@ const DashboardPage: FC = () => {
href: "/dashboard/sessions", href: "/dashboard/sessions",
variant: "success" as const, variant: "success" as const,
features: ["Session search", "Conversation details", "Export data"], features: ["Session search", "Conversation details", "Export data"],
adminOnly: false,
}, },
...(session?.user?.role === "ADMIN" ];
? [
if (session?.user?.role === "ADMIN") {
return [
...baseCards,
{ {
title: "Company Settings", title: "Company Settings",
description: description:
@@ -92,11 +83,13 @@ const DashboardPage: FC = () => {
features: ["User invitations", "Role management", "Access control"], features: ["User invitations", "Role management", "Access control"],
adminOnly: true, adminOnly: true,
}, },
]
: []),
]; ];
}
return baseCards;
}, [session?.user?.role]);
const getCardClasses = (variant: string) => { // Memoize class getter functions
const getCardClasses = useCallback((variant: string) => {
switch (variant) { switch (variant) {
case "primary": case "primary":
return "border-primary/20 bg-linear-to-br from-primary/5 to-primary/10 hover:from-primary/10 hover:to-primary/15"; return "border-primary/20 bg-linear-to-br from-primary/5 to-primary/10 hover:from-primary/10 hover:to-primary/15";
@@ -107,9 +100,9 @@ const DashboardPage: FC = () => {
default: default:
return "border-border bg-linear-to-br from-card to-muted/20 hover:from-muted/30 hover:to-muted/40"; return "border-border bg-linear-to-br from-card to-muted/20 hover:from-muted/30 hover:to-muted/40";
} }
}; }, []);
const getIconClasses = (variant: string) => { const getIconClasses = useCallback((variant: string) => {
switch (variant) { switch (variant) {
case "primary": case "primary":
return "bg-primary/10 text-primary border-primary/20"; return "bg-primary/10 text-primary border-primary/20";
@@ -120,7 +113,31 @@ const DashboardPage: FC = () => {
default: default:
return "bg-muted text-muted-foreground border-border"; return "bg-muted text-muted-foreground border-border";
} }
}; }, []);
// Navigate handler to avoid creating new functions on each render
const handleNavigate = useCallback(
(href: string) => {
router.push(href);
},
[router]
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="relative">
<div className="animate-spin rounded-full h-12 w-12 border-2 border-muted border-t-primary mx-auto" />
<div className="absolute inset-0 animate-ping rounded-full h-12 w-12 border border-primary opacity-20 mx-auto" />
</div>
<p className="text-lg text-muted-foreground animate-pulse">
Loading dashboard...
</p>
</div>
</div>
);
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -163,7 +180,7 @@ const DashboardPage: FC = () => {
className={`relative overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 cursor-pointer group ${getCardClasses( className={`relative overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 cursor-pointer group ${getCardClasses(
card.variant card.variant
)}`} )}`}
onClick={() => router.push(card.href)} onClick={() => handleNavigate(card.href)}
> >
{/* Subtle gradient overlay */} {/* Subtle gradient overlay */}
<div className="absolute inset-0 bg-linear-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" /> <div className="absolute inset-0 bg-linear-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
@@ -219,7 +236,7 @@ const DashboardPage: FC = () => {
variant={card.variant === "primary" ? "default" : "outline"} variant={card.variant === "primary" ? "default" : "outline"}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
router.push(card.href); handleNavigate(card.href);
}} }}
> >
<span> <span>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useMemo } from "react";
import { getLocalizedCountryName } from "../lib/localization"; import { getLocalizedCountryName } from "../lib/localization";
interface CountryDisplayProps { interface CountryDisplayProps {
@@ -16,16 +16,11 @@ export default function CountryDisplay({
countryCode, countryCode,
className, className,
}: CountryDisplayProps) { }: CountryDisplayProps) {
const [countryName, setCountryName] = useState<string>( // Compute directly - Intl.DisplayNames is synchronous
countryCode || "Unknown" const countryName = useMemo(
() => (countryCode ? getLocalizedCountryName(countryCode) : "Unknown"),
[countryCode]
); );
useEffect(() => {
// Only run in the browser and if we have a valid code
if (typeof window !== "undefined" && countryCode) {
setCountryName(getLocalizedCountryName(countryCode));
}
}, [countryCode]);
return <span className={className}>{countryName}</span>; return <span className={className}>{countryName}</span>;
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useMemo } from "react";
import { getLocalizedLanguageName } from "../lib/localization"; import { getLocalizedLanguageName } from "../lib/localization";
interface LanguageDisplayProps { interface LanguageDisplayProps {
@@ -16,16 +16,11 @@ export default function LanguageDisplay({
languageCode, languageCode,
className, className,
}: LanguageDisplayProps) { }: LanguageDisplayProps) {
const [languageName, setLanguageName] = useState<string>( // Compute directly - Intl.DisplayNames is synchronous
languageCode || "Unknown" const languageName = useMemo(
() => (languageCode ? getLocalizedLanguageName(languageCode) : "Unknown"),
[languageCode]
); );
useEffect(() => {
// Only run in the browser and if we have a valid code
if (typeof window !== "undefined" && languageCode) {
setLanguageName(getLocalizedLanguageName(languageCode));
}
}, [languageCode]);
return <span className={className}>{languageName}</span>; return <span className={className}>{languageName}</span>;
} }

View File

@@ -39,7 +39,7 @@ export default function TopQuestionsChart({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.map((question) => { {data.map((question, index) => {
const percentage = const percentage =
maxCount > 0 ? (question.count / maxCount) * 100 : 0; maxCount > 0 ? (question.count / maxCount) * 100 : 0;

View File

@@ -1,3 +1,5 @@
import nextTypescript from "eslint-config-next/typescript";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc"; import { FlatCompat } from "@eslint/eslintrc";
@@ -10,11 +12,10 @@ const compat = new FlatCompat({
}); });
const eslintConfig = [ const eslintConfig = [
...nextTypescript,
js.configs.recommended, js.configs.recommended,
...compat.extends( ...nextCoreWebVitals,
"next/core-web-vitals", ...compat.extends("plugin:@typescript-eslint/recommended"),
"plugin:@typescript-eslint/recommended"
),
{ {
ignores: [ ignores: [
"node_modules/", "node_modules/",
@@ -34,7 +35,7 @@ const eslintConfig = [
"prefer-const": "error", "prefer-const": "error",
"no-unused-vars": "warn", "no-unused-vars": "warn",
}, },
}, }
]; ];
export default eslintConfig; export default eslintConfig;

View File

@@ -9,7 +9,7 @@
"dev:next-only": "next dev --turbopack", "dev:next-only": "next dev --turbopack",
"format": "npx prettier --write .", "format": "npx prettier --write .",
"format:check": "npx prettier --check .", "format:check": "npx prettier --check .",
"lint": "next lint", "lint": "eslint .",
"lint:fix": "npx eslint --fix", "lint:fix": "npx eslint --fix",
"biome:check": "biome check .", "biome:check": "biome check .",
"biome:fix": "biome check --write .", "biome:fix": "biome check --write .",
@@ -33,100 +33,99 @@
"lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\"" "lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\""
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^6.10.1", "@prisma/adapter-pg": "^6.19.2",
"@prisma/client": "^6.10.1", "@prisma/client": "^6.19.2",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.8",
"@rapideditor/country-coder": "^5.4.0", "@rapideditor/country-coder": "^5.6.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/d3-cloud": "^1.2.9", "@types/d3-cloud": "^1.2.9",
"@types/d3-selection": "^3.0.11", "@types/d3-selection": "^3.0.11",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.19", "@types/leaflet": "^1.9.21",
"@types/node-fetch": "^2.6.12", "@types/node-fetch": "^2.6.13",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.3",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"d3-cloud": "^1.2.7", "d3-cloud": "^1.2.8",
"d3-selection": "^3.0.0", "d3-selection": "^3.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"iso-639-1": "^3.1.5", "iso-639-1": "^3.1.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"motion": "^12.19.2", "motion": "^12.27.1",
"next": "^15.3.4", "next": "^15.5.9",
"next-auth": "^4.24.11", "next-auth": "^4.24.13",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.1.1", "node-cron": "^4.2.1",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"pg": "^8.16.3", "pg": "^8.17.1",
"react": "^19.1.0", "react": "^19.2.3",
"react-day-picker": "^9.7.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.1.0", "react-dom": "^19.2.3",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"recharts": "^3.0.2", "recharts": "^3.6.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"sonner": "^2.0.5", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.4.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.25.67" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.39.2",
"@eslint/js": "^9.30.0", "@playwright/test": "^1.57.0",
"@playwright/test": "^1.53.1", "@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/postcss": "^4.1.11", "@testing-library/dom": "^10.4.1",
"@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.0", "@types/node": "^24.10.9",
"@types/node": "^24.0.6",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/pg": "^8.15.4", "@types/pg": "^8.16.0",
"@types/react": "^19.1.8", "@types/react": "^19.2.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.35.0", "@typescript-eslint/parser": "^8.53.1",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"concurrently": "^9.2.0", "concurrently": "^9.2.1",
"eslint": "^9.30.0", "eslint": "^9.39.2",
"eslint-config-next": "^15.3.4", "eslint-config-next": "^15.5.9",
"eslint-plugin-prettier": "^5.5.1", "eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jest-axe": "^10.0.0", "jest-axe": "^10.0.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"markdownlint-cli2": "^0.18.1", "markdownlint-cli2": "^0.18.1",
"node-mocks-http": "^1.17.2", "node-mocks-http": "^1.17.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.8.0",
"prettier-plugin-jinja-template": "^2.1.0", "prettier-plugin-jinja-template": "^2.1.0",
"prisma": "^6.10.1", "prisma": "^6.19.2",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.18",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.3", "tsx": "^4.21.0",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },