mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-02-13 14:15:44 +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,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -35,8 +36,17 @@ import GeographicMap from "../../../components/GeographicMap";
|
||||
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
||||
import MetricCard from "../../../components/ui/metric-card";
|
||||
import WordCloud from "../../../components/WordCloud";
|
||||
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
||||
import type { Company, MetricsResult } 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
|
||||
function DashboardContent() {
|
||||
@@ -93,35 +103,123 @@ function DashboardContent() {
|
||||
}
|
||||
}, [status, router, isInitialLoad, fetchMetrics]);
|
||||
|
||||
async function handleRefresh() {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (isAuditor) return;
|
||||
if (!company?.id) {
|
||||
alert("Cannot refresh: Company ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
setRefreshing(true);
|
||||
// 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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
}),
|
||||
fetch("/api/dashboard/metrics"),
|
||||
]);
|
||||
|
||||
if (!company?.id) {
|
||||
setRefreshing(false);
|
||||
alert("Cannot refresh: Company ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/admin/refresh-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const metricsRes = await fetch("/api/dashboard/metrics");
|
||||
if (refreshRes.ok) {
|
||||
const data = await metricsRes.json();
|
||||
setMetrics(data.metrics);
|
||||
} else {
|
||||
const errorData = await res.json();
|
||||
const errorData = await refreshRes.json();
|
||||
alert(`Failed to refresh sessions: ${errorData.error}`);
|
||||
}
|
||||
} finally {
|
||||
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
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Modern Header */}
|
||||
@@ -475,14 +478,14 @@ function DashboardContent() {
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<ModernLineChart
|
||||
data={getSessionsOverTimeData()}
|
||||
data={sessionsOverTimeData}
|
||||
title="Sessions Over Time"
|
||||
className="lg:col-span-2"
|
||||
height={350}
|
||||
/>
|
||||
|
||||
<ModernDonutChart
|
||||
data={getSentimentData()}
|
||||
data={sentimentData}
|
||||
title="Conversation Sentiment"
|
||||
centerText={{
|
||||
title: "Total",
|
||||
@@ -494,13 +497,13 @@ function DashboardContent() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ModernBarChart
|
||||
data={getCategoriesData()}
|
||||
data={categoriesData}
|
||||
title="Sessions by Category"
|
||||
height={350}
|
||||
/>
|
||||
|
||||
<ModernDonutChart
|
||||
data={getLanguagesData()}
|
||||
data={languagesData}
|
||||
title="Languages Used"
|
||||
height={350}
|
||||
/>
|
||||
@@ -516,7 +519,7 @@ function DashboardContent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GeographicMap countries={getCountryData()} />
|
||||
<GeographicMap countries={countryData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -529,7 +532,7 @@ function DashboardContent() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<WordCloud words={getWordCloudData()} width={500} height={300} />
|
||||
<WordCloud words={wordCloudData} width={500} height={300} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -545,7 +548,7 @@ function DashboardContent() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponseTimeDistribution
|
||||
data={getResponseTimeData()}
|
||||
data={responseTimeData}
|
||||
average={metrics.avgResponseTime || 0}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -31,6 +31,98 @@ const DashboardPage: FC = () => {
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Memoize navigation cards - only recalculates when user role changes
|
||||
const navigationCards = useMemo(() => {
|
||||
const baseCards = [
|
||||
{
|
||||
title: "Analytics Overview",
|
||||
description:
|
||||
"View comprehensive metrics, charts, and insights from your chat sessions",
|
||||
icon: <BarChart3 className="h-6 w-6" />,
|
||||
href: "/dashboard/overview",
|
||||
variant: "primary" as const,
|
||||
features: ["Real-time metrics", "Interactive charts", "Trend analysis"],
|
||||
adminOnly: false,
|
||||
},
|
||||
{
|
||||
title: "Session Browser",
|
||||
description:
|
||||
"Browse, search, and analyze individual conversation sessions",
|
||||
icon: <MessageSquare className="h-6 w-6" />,
|
||||
href: "/dashboard/sessions",
|
||||
variant: "success" as const,
|
||||
features: ["Session search", "Conversation details", "Export data"],
|
||||
adminOnly: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (session?.user?.role === "ADMIN") {
|
||||
return [
|
||||
...baseCards,
|
||||
{
|
||||
title: "Company Settings",
|
||||
description:
|
||||
"Configure company settings, integrations, and API connections",
|
||||
icon: <Settings className="h-6 w-6" />,
|
||||
href: "/dashboard/company",
|
||||
variant: "warning" as const,
|
||||
features: [
|
||||
"API configuration",
|
||||
"Integration settings",
|
||||
"Data management",
|
||||
],
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: "User Management",
|
||||
description:
|
||||
"Invite team members and manage user accounts and permissions",
|
||||
icon: <Users className="h-6 w-6" />,
|
||||
href: "/dashboard/users",
|
||||
variant: "default" as const,
|
||||
features: ["User invitations", "Role management", "Access control"],
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return baseCards;
|
||||
}, [session?.user?.role]);
|
||||
|
||||
// Memoize class getter functions
|
||||
const getCardClasses = useCallback((variant: string) => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "border-primary/20 bg-linear-to-br from-primary/5 to-primary/10 hover:from-primary/10 hover:to-primary/15";
|
||||
case "success":
|
||||
return "border-green-200 bg-linear-to-br from-green-50 to-green-100 hover:from-green-100 hover:to-green-150 dark:border-green-800 dark:from-green-950 dark:to-green-900";
|
||||
case "warning":
|
||||
return "border-amber-200 bg-linear-to-br from-amber-50 to-amber-100 hover:from-amber-100 hover:to-amber-150 dark:border-amber-800 dark:from-amber-950 dark:to-amber-900";
|
||||
default:
|
||||
return "border-border bg-linear-to-br from-card to-muted/20 hover:from-muted/30 hover:to-muted/40";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getIconClasses = useCallback((variant: string) => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "bg-primary/10 text-primary border-primary/20";
|
||||
case "success":
|
||||
return "bg-green-100 text-green-600 border-green-200 dark:bg-green-900 dark:text-green-400 dark:border-green-800";
|
||||
case "warning":
|
||||
return "bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900 dark:text-amber-400 dark:border-amber-800";
|
||||
default:
|
||||
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]">
|
||||
@@ -47,81 +139,6 @@ const DashboardPage: FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const navigationCards = [
|
||||
{
|
||||
title: "Analytics Overview",
|
||||
description:
|
||||
"View comprehensive metrics, charts, and insights from your chat sessions",
|
||||
icon: <BarChart3 className="h-6 w-6" />,
|
||||
href: "/dashboard/overview",
|
||||
variant: "primary" as const,
|
||||
features: ["Real-time metrics", "Interactive charts", "Trend analysis"],
|
||||
},
|
||||
{
|
||||
title: "Session Browser",
|
||||
description:
|
||||
"Browse, search, and analyze individual conversation sessions",
|
||||
icon: <MessageSquare className="h-6 w-6" />,
|
||||
href: "/dashboard/sessions",
|
||||
variant: "success" as const,
|
||||
features: ["Session search", "Conversation details", "Export data"],
|
||||
},
|
||||
...(session?.user?.role === "ADMIN"
|
||||
? [
|
||||
{
|
||||
title: "Company Settings",
|
||||
description:
|
||||
"Configure company settings, integrations, and API connections",
|
||||
icon: <Settings className="h-6 w-6" />,
|
||||
href: "/dashboard/company",
|
||||
variant: "warning" as const,
|
||||
features: [
|
||||
"API configuration",
|
||||
"Integration settings",
|
||||
"Data management",
|
||||
],
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
title: "User Management",
|
||||
description:
|
||||
"Invite team members and manage user accounts and permissions",
|
||||
icon: <Users className="h-6 w-6" />,
|
||||
href: "/dashboard/users",
|
||||
variant: "default" as const,
|
||||
features: ["User invitations", "Role management", "Access control"],
|
||||
adminOnly: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const getCardClasses = (variant: string) => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "border-primary/20 bg-linear-to-br from-primary/5 to-primary/10 hover:from-primary/10 hover:to-primary/15";
|
||||
case "success":
|
||||
return "border-green-200 bg-linear-to-br from-green-50 to-green-100 hover:from-green-100 hover:to-green-150 dark:border-green-800 dark:from-green-950 dark:to-green-900";
|
||||
case "warning":
|
||||
return "border-amber-200 bg-linear-to-br from-amber-50 to-amber-100 hover:from-amber-100 hover:to-amber-150 dark:border-amber-800 dark:from-amber-950 dark:to-amber-900";
|
||||
default:
|
||||
return "border-border bg-linear-to-br from-card to-muted/20 hover:from-muted/30 hover:to-muted/40";
|
||||
}
|
||||
};
|
||||
|
||||
const getIconClasses = (variant: string) => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "bg-primary/10 text-primary border-primary/20";
|
||||
case "success":
|
||||
return "bg-green-100 text-green-600 border-green-200 dark:bg-green-900 dark:text-green-400 dark:border-green-800";
|
||||
case "warning":
|
||||
return "bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900 dark:text-amber-400 dark:border-amber-800";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome Header */}
|
||||
@@ -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(
|
||||
card.variant
|
||||
)}`}
|
||||
onClick={() => router.push(card.href)}
|
||||
onClick={() => handleNavigate(card.href)}
|
||||
>
|
||||
{/* Subtle gradient overlay */}
|
||||
<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"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(card.href);
|
||||
handleNavigate(card.href);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { getLocalizedCountryName } from "../lib/localization";
|
||||
|
||||
interface CountryDisplayProps {
|
||||
@@ -16,16 +16,11 @@ export default function CountryDisplay({
|
||||
countryCode,
|
||||
className,
|
||||
}: CountryDisplayProps) {
|
||||
const [countryName, setCountryName] = useState<string>(
|
||||
countryCode || "Unknown"
|
||||
// Compute directly - Intl.DisplayNames is synchronous
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { getLocalizedLanguageName } from "../lib/localization";
|
||||
|
||||
interface LanguageDisplayProps {
|
||||
@@ -16,16 +16,11 @@ export default function LanguageDisplay({
|
||||
languageCode,
|
||||
className,
|
||||
}: LanguageDisplayProps) {
|
||||
const [languageName, setLanguageName] = useState<string>(
|
||||
languageCode || "Unknown"
|
||||
// Compute directly - Intl.DisplayNames is synchronous
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function TopQuestionsChart({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.map((question) => {
|
||||
{data.map((question, index) => {
|
||||
const percentage =
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
@@ -10,11 +12,10 @@ const compat = new FlatCompat({
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...nextTypescript,
|
||||
js.configs.recommended,
|
||||
...compat.extends(
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
),
|
||||
...nextCoreWebVitals,
|
||||
...compat.extends("plugin:@typescript-eslint/recommended"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/",
|
||||
@@ -34,7 +35,7 @@ const eslintConfig = [
|
||||
"prefer-const": "error",
|
||||
"no-unused-vars": "warn",
|
||||
},
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
121
package.json
121
package.json
@@ -9,7 +9,7 @@
|
||||
"dev:next-only": "next dev --turbopack",
|
||||
"format": "npx prettier --write .",
|
||||
"format:check": "npx prettier --check .",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npx eslint --fix",
|
||||
"biome:check": "biome check .",
|
||||
"biome:fix": "biome check --write .",
|
||||
@@ -33,100 +33,99 @@
|
||||
"lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^6.10.1",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@rapideditor/country-coder": "^5.4.0",
|
||||
"@prisma/adapter-pg": "^6.19.2",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@rapideditor/country-coder": "^5.6.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/d3-cloud": "^1.2.9",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/leaflet": "^1.9.19",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"csv-parse": "^5.6.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-cloud": "^1.2.7",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"d3-selection": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"iso-639-1": "^3.1.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"motion": "^12.19.2",
|
||||
"next": "^15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"motion": "^12.27.1",
|
||||
"next": "^15.5.9",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"pg": "^8.17.1",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.0.2",
|
||||
"recharts": "^3.6.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.67"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.30.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^24.0.6",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.35.0",
|
||||
"@typescript-eslint/parser": "^8.35.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||
"@typescript-eslint/parser": "^8.53.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^9.30.0",
|
||||
"eslint-config-next": "^15.3.4",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^15.5.9",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"markdownlint-cli2": "^0.18.1",
|
||||
"node-mocks-http": "^1.17.2",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.8.0",
|
||||
"prettier-plugin-jinja-template": "^2.1.0",
|
||||
"prisma": "^6.10.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"prisma": "^6.19.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.3",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user