From 8b3846539f4c9bd32e6b1127b1cebbc7802fa028 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Tue, 20 Jan 2026 07:05:27 +0100 Subject: [PATCH] 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) --- app/dashboard/overview/page.tsx | 249 ++++++++++++++++--------------- app/dashboard/page.tsx | 173 +++++++++++---------- components/CountryDisplay.tsx | 15 +- components/LanguageDisplay.tsx | 15 +- components/TopQuestionsChart.tsx | 2 +- eslint.config.js | 11 +- package.json | 121 ++++++++------- 7 files changed, 298 insertions(+), 288 deletions(-) diff --git a/app/dashboard/overview/page.tsx b/app/dashboard/overview/page.tsx index 098a694..e9478e5 100644 --- a/app/dashboard/overview/page.tsx +++ b/app/dashboard/overview/page.tsx @@ -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: () => ( +
+ +
+ ), +}); // 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 + ); + }, [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 - ); - }; - - 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 (
{/* Modern Header */} @@ -475,14 +478,14 @@ function DashboardContent() { {/* Charts Section */}
@@ -516,7 +519,7 @@ function DashboardContent() { - + @@ -529,7 +532,7 @@ function DashboardContent() {
- +
@@ -545,7 +548,7 @@ function DashboardContent() { diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 98c2d1f..1899b31 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -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: , + 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: , + 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: , + 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: , + 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 (
@@ -47,81 +139,6 @@ const DashboardPage: FC = () => { ); } - const navigationCards = [ - { - title: "Analytics Overview", - description: - "View comprehensive metrics, charts, and insights from your chat sessions", - icon: , - 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: , - 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: , - 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: , - 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 (
{/* 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 */}
@@ -219,7 +236,7 @@ const DashboardPage: FC = () => { variant={card.variant === "primary" ? "default" : "outline"} onClick={(e) => { e.stopPropagation(); - router.push(card.href); + handleNavigate(card.href); }} > diff --git a/components/CountryDisplay.tsx b/components/CountryDisplay.tsx index 2b6273f..a360faf 100644 --- a/components/CountryDisplay.tsx +++ b/components/CountryDisplay.tsx @@ -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( - 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 {countryName}; } diff --git a/components/LanguageDisplay.tsx b/components/LanguageDisplay.tsx index 85028d1..10eaecb 100644 --- a/components/LanguageDisplay.tsx +++ b/components/LanguageDisplay.tsx @@ -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( - 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 {languageName}; } diff --git a/components/TopQuestionsChart.tsx b/components/TopQuestionsChart.tsx index 7814abc..9e9d7be 100644 --- a/components/TopQuestionsChart.tsx +++ b/components/TopQuestionsChart.tsx @@ -39,7 +39,7 @@ export default function TopQuestionsChart({
- {data.map((question) => { + {data.map((question, index) => { const percentage = maxCount > 0 ? (question.count / maxCount) * 100 : 0; diff --git a/eslint.config.js b/eslint.config.js index b449b6e..41482c0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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; diff --git a/package.json b/package.json index bccfeec..836ac9b 100644 --- a/package.json +++ b/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" },