mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-02-13 15:15:45 +01:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
121
package.json
121
package.json
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user