diff --git a/package-lock.json b/package-lock.json index 6b5cbaf..c3e0f1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^3.8.1", + "swr": "^2.4.1", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" @@ -4708,6 +4709,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "devOptional": true, @@ -9589,6 +9599,19 @@ "version": "3.2.0", "license": "ISC" }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tabbable": { "version": "6.4.0", "license": "MIT" diff --git a/package.json b/package.json index 24fb217..176530c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^3.8.1", + "swr": "^2.4.1", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "zod": "^4.3.6" diff --git a/src/app/[username]/ProfileClient.tsx b/src/app/[username]/ProfileClient.tsx index a43bc68..c25b00a 100644 --- a/src/app/[username]/ProfileClient.tsx +++ b/src/app/[username]/ProfileClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useCallback } from "react"; +import useSWR from "swr"; import { useRouter } from "next/navigation"; import dynamic from "next/dynamic"; import { PDFExportButton } from "@/components/PDFExportButton"; @@ -18,6 +19,10 @@ import { } from "lucide-react"; import { AnalysisResult } from "@/types"; import { fetchAuthIdentity } from "@/lib/client-auth"; +import { + fetchProfileAnalysis, + ProfileAnalysisError, +} from "@/lib/profile-analysis"; const StatsDashboard = dynamic( () => @@ -37,35 +42,66 @@ interface ProfileClientProps { export function ProfileClient({ username, initialData }: ProfileClientProps) { const router = useRouter(); - const [data, setData] = useState(initialData || null); const [error, setError] = useState(null); const [showStarModal, setShowStarModal] = useState(false); const [isOwner, setIsOwner] = useState(false); const [isVerifyingAgain, setIsVerifyingAgain] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); + const safeUsername = (username || "").toLowerCase(); + const isInvalidUsername = + !username || safeUsername === "undefined" || safeUsername === "null"; + + const { data, error: fetchError, mutate } = useSWR( + isInvalidUsername ? null : ["profile-analysis", username], + ([, activeUsername]) => fetchProfileAnalysis(activeUsername), + { + fallbackData: initialData, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 5 * 60 * 1000, + keepPreviousData: true, + }, + ); - const fetchData = useCallback( - async (force = false, nosave = false) => { - try { - setIsRefreshing(force); - const baseUrl = `/api/analyze?username=${username}`; - const res = await fetch( - `${baseUrl}${force ? "&force=true" : ""}${nosave ? "&nosave=true" : ""}`, - ); - const result = await res.json(); - - if (res.status === 403 && result.error === "Star required") { - setShowStarModal(true); - return; - } + useEffect(() => { + if (fetchError) { + if ( + fetchError instanceof ProfileAnalysisError && + fetchError.code === "STAR_REQUIRED" + ) { + setShowStarModal(true); + setError(null); + return; + } - if (!res.ok) { - setError(result.error || "Diagnostic matrix failed"); - return; - } + setShowStarModal(false); + setError( + fetchError instanceof Error + ? fetchError.message + : "Diagnostic matrix failed", + ); + return; + } - setData(result); - setError(null); + if (!isInvalidUsername) { + setError(null); + } + }, [fetchError, isInvalidUsername]); + + useEffect(() => { + if (data) { + setShowStarModal(false); + } + }, [data]); + + const refreshAnalysis = useCallback( + async (options: { force?: boolean; nosave?: boolean } = {}) => { + setIsRefreshing(true); + + try { + const nextData = await fetchProfileAnalysis(username, options); + await mutate(nextData, { revalidate: false }); const confetti = (await import("canvas-confetti")).default; confetti({ @@ -74,18 +110,30 @@ export function ProfileClient({ username, initialData }: ProfileClientProps) { origin: { y: 0.6 }, colors: ["#FFE600", "#FF00E5", "#00F0FF", "#000000"], }); - } catch { - setError("NETWORK_FAILURE"); + } catch (refreshError) { + if ( + refreshError instanceof ProfileAnalysisError && + refreshError.code === "STAR_REQUIRED" + ) { + setShowStarModal(true); + setError(null); + } else { + setShowStarModal(false); + setError( + refreshError instanceof Error + ? refreshError.message + : "NETWORK_FAILURE", + ); + } } finally { setIsRefreshing(false); } }, - [username], + [mutate, username], ); useEffect(() => { - const safeUsername = (username || "").toLowerCase(); - if (!username || safeUsername === "undefined" || safeUsername === "null") { + if (isInvalidUsername) { setError("INVALID_ID_SPEC"); return; } @@ -97,17 +145,13 @@ export function ProfileClient({ username, initialData }: ProfileClientProps) { .catch(() => { setIsOwner(false); }); - - if (!initialData) { - void fetchData(); - } - }, [username, initialData, fetchData]); + }, [isInvalidUsername, safeUsername]); const handleRecheckStar = async () => { setIsVerifyingAgain(true); try { await new Promise((resolve) => setTimeout(resolve, 1000)); - await fetchData(); + await refreshAnalysis(); } finally { setIsVerifyingAgain(false); } @@ -231,7 +275,7 @@ export function ProfileClient({ username, initialData }: ProfileClientProps) { {(data.isLocked || data.isHistorical) && (