feat: add view responses page
Docker Build and Push / build-and-push (push) Has been cancelled

This commit is contained in:
2026-02-22 19:03:35 +07:00
parent 4014ec802a
commit 218e8d10a0
8 changed files with 1076 additions and 7 deletions
+1
View File
@@ -8,6 +8,7 @@ export default [
route("forms/new", "routes/create-form.tsx"), route("forms/new", "routes/create-form.tsx"),
route("form/:id", "routes/form.tsx"), route("form/:id", "routes/form.tsx"),
route("form/:id/submit", "routes/submit-form.tsx"), route("form/:id/submit", "routes/submit-form.tsx"),
route("form/:id/responses", "routes/form-responses.tsx"),
route("form/:id/edit", "routes/edit-form.tsx"), route("form/:id/edit", "routes/edit-form.tsx"),
route("*", "routes/not-found.tsx"), route("*", "routes/not-found.tsx"),
] satisfies RouteConfig; ] satisfies RouteConfig;
+651
View File
@@ -0,0 +1,651 @@
import { useState, useEffect, useMemo } from "react"
import { Link, useParams, useNavigate } from "react-router"
import {
ArrowLeft,
BarChart3,
Calendar,
Clock,
FileText,
MessageSquare,
Users,
} from "lucide-react"
import {
PieChart,
Pie,
Cell,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts"
import { Navbar } from "@/components/shared/navbar"
import { Footer } from "@/components/shared/footer"
import { FormButton } from "@/components/shared/form-button"
import { useAuth } from "@/app/context/auth-context"
import { getFormById, getFormResponses } from "@/lib/api"
import type { FormDetail, FormResponse, Question, QuestionType } from "@/lib/types"
const CHART_COLORS = [
"hsl(221, 83%, 53%)",
"hsl(142, 71%, 45%)",
"hsl(38, 92%, 50%)",
"hsl(0, 84%, 60%)",
"hsl(262, 83%, 58%)",
"hsl(172, 66%, 50%)",
"hsl(326, 80%, 55%)",
"hsl(25, 95%, 53%)",
"hsl(199, 89%, 48%)",
"hsl(47, 96%, 53%)",
]
type TabType = "summary" | "individual"
export default function FormResponsesPage() {
const { id } = useParams()
const navigate = useNavigate()
const { user, loading: authLoading } = useAuth()
const [form, setForm] = useState<FormDetail | null>(null)
const [responses, setResponses] = useState<FormResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabType>("summary")
useEffect(() => {
if (!authLoading && !user) {
navigate("/login")
}
}, [user, authLoading, navigate])
useEffect(() => {
if (!id || !user) return
async function fetchData() {
try {
const [formData, responsesData] = await Promise.all([
getFormById(id!),
getFormResponses(id!),
])
setForm(formData)
setResponses(responsesData)
} catch (err) {
if (err instanceof Response && err.status === 404) {
setError("Form not found")
} else {
setError("Failed to load responses. Please try again.")
}
} finally {
setLoading(false)
}
}
fetchData()
}, [id, user])
if (authLoading || loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<p className="text-muted-foreground">Loading responses...</p>
</div>
)
}
if (error || !form) {
return (
<div className="flex min-h-screen flex-col bg-background">
<Navbar />
<main className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-sm text-destructive">{error ?? "Form not found"}</p>
<Link
to="/forms"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Back to forms
</Link>
</div>
</main>
<Footer />
</div>
)
}
if (!user) return null
return (
<div className="flex min-h-screen flex-col bg-background">
<Navbar />
<main className="flex-1">
<div className="mx-auto max-w-5xl px-4 py-8 lg:px-8">
<Link
to={`/form/${id}`}
className="mb-6 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to form
</Link>
{/* Header */}
<div className="mb-8 rounded-xl border border-border bg-card shadow-sm">
<div className="h-2 rounded-t-xl bg-primary" />
<div className="p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-bold text-foreground sm:text-2xl">
{form.title}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{form.description}
</p>
</div>
<div className="flex items-center gap-2">
<Link to={`/form/${id}`}>
<FormButton variant="outline" size="sm">
<FileText className="h-4 w-4" />
Preview
</FormButton>
</Link>
</div>
</div>
{/* Stats */}
<div className="mt-5 grid grid-cols-2 gap-4 border-t border-border pt-5 sm:grid-cols-4">
<StatCard
icon={<Users className="h-4 w-4 text-primary" />}
label="Responses"
value={responses.length}
/>
<StatCard
icon={<FileText className="h-4 w-4 text-primary" />}
label="Questions"
value={form.questions.length}
/>
<StatCard
icon={<Calendar className="h-4 w-4 text-primary" />}
label="Created"
value={new Date(form.created_at).toLocaleDateString()}
/>
<StatCard
icon={<Clock className="h-4 w-4 text-primary" />}
label="Last response"
value={
responses.length > 0
? new Date(responses[0].submitted_at).toLocaleDateString()
: "—"
}
/>
</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-1 rounded-lg border border-border bg-card p-1">
<button
onClick={() => setActiveTab("summary")}
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
activeTab === "summary"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<BarChart3 className="mr-1.5 inline-block h-4 w-4" />
Summary
</button>
<button
onClick={() => setActiveTab("individual")}
className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
activeTab === "individual"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<MessageSquare className="mr-1.5 inline-block h-4 w-4" />
Individual ({responses.length})
</button>
</div>
{responses.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/50 py-16">
<Users className="mb-3 h-10 w-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
No responses yet. Share this form to start collecting data.
</p>
<Link to={`/form/${id}/submit`} className="mt-3">
<FormButton variant="outline" size="sm">
View submission page
</FormButton>
</Link>
</div>
) : activeTab === "summary" ? (
<SummaryTab form={form} responses={responses} />
) : (
<IndividualTab form={form} responses={responses} />
)}
</div>
</main>
<Footer />
</div>
)
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string | number
}) {
return (
<div className="rounded-lg border border-border bg-background p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
{label}
</div>
<p className="mt-1 text-lg font-semibold text-foreground">{value}</p>
</div>
)
}
function SummaryTab({
form,
responses,
}: {
form: FormDetail
responses: FormResponse[]
}) {
return (
<div className="flex flex-col gap-6">
{form.questions.map((question, index) => (
<QuestionSummary
key={question.id}
question={question}
index={index}
responses={responses}
totalResponses={responses.length}
/>
))}
</div>
)
}
function QuestionSummary({
question,
index,
responses,
totalResponses,
}: {
question: Question
index: number
responses: FormResponse[]
totalResponses: number
}) {
const answersForQuestion = useMemo(() => {
return responses
.map((r) => r.answers.find((a) => a.question_id === question.id))
.filter(Boolean)
}, [responses, question.id])
const responseCount = answersForQuestion.length
const skipped = totalResponses - responseCount
return (
<div className="rounded-xl border border-border bg-card shadow-sm">
<div className="border-b border-border p-5">
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
{index + 1}
</span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-foreground">
{question.title}
{question.required && (
<span className="ml-1 text-destructive">*</span>
)}
</h3>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="capitalize">{question.type.replace("_", " ")}</span>
<span>·</span>
<span>{responseCount} answers</span>
{skipped > 0 && (
<>
<span>·</span>
<span>{skipped} skipped</span>
</>
)}
</div>
</div>
</div>
</div>
<div className="p-5">
{hasChartableType(question.type) ? (
<ChartVisualization
question={question}
responses={responses}
/>
) : (
<TextResponses
questionId={question.id}
responses={responses}
/>
)}
</div>
</div>
)
}
function hasChartableType(type: QuestionType): boolean {
return ["multiple_choice", "checkbox", "dropdown", "rating"].includes(type)
}
function ChartVisualization({
question,
responses,
}: {
question: Question
responses: FormResponse[]
}) {
const chartData = useMemo(() => {
const counts: Record<string, number> = {}
if (["multiple_choice", "dropdown"].includes(question.type)) {
question.options.forEach((opt) => {
counts[opt.label] = 0
})
}
if (question.type === "checkbox") {
question.options.forEach((opt) => {
counts[opt.label] = 0
})
}
if (question.type === "rating") {
for (let i = 1; i <= 5; i++) {
counts[String(i)] = 0
}
}
responses.forEach((r) => {
const answer = r.answers.find((a) => a.question_id === question.id)
if (!answer) return
if (question.type === "checkbox") {
const selected = answer.answer.split(",").map((s) => s.trim())
selected.forEach((s) => {
counts[s] = (counts[s] || 0) + 1
})
} else {
counts[answer.answer] = (counts[answer.answer] || 0) + 1
}
})
return Object.entries(counts).map(([name, value]) => ({
name,
value,
}))
}, [question, responses])
const total = chartData.reduce((sum, d) => sum + d.value, 0)
if (question.type === "rating") {
const avgRating = useMemo(() => {
let sum = 0
let count = 0
responses.forEach((r) => {
const answer = r.answers.find((a) => a.question_id === question.id)
if (answer) {
sum += Number(answer.answer)
count++
}
})
return count > 0 ? (sum / count).toFixed(1) : "—"
}, [responses, question.id])
return (
<div>
<div className="mb-4 flex items-center gap-3">
<span className="text-3xl font-bold text-foreground">{avgRating}</span>
<span className="text-sm text-muted-foreground">average rating out of 5</span>
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="name"
tick={{ fontSize: 12 }}
tickFormatter={(v) => `${v}`}
className="fill-muted-foreground"
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
className="fill-muted-foreground"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{chartData.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
return (
<div className="flex flex-col gap-6 sm:flex-row sm:items-center">
<div className="mx-auto w-full max-w-[220px] sm:mx-0">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{chartData.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value: number | undefined) => [
`${value ?? 0} (${total > 0 ? (((value ?? 0) / total) * 100).toFixed(0) : 0}%)`,
"Responses",
]}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-1 flex-col gap-2">
{chartData.map((item, i) => (
<div key={item.name} className="flex items-center gap-3">
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }}
/>
<span className="flex-1 text-sm text-foreground">{item.name}</span>
<span className="text-sm font-medium text-foreground">{item.value}</span>
<span className="w-12 text-right text-xs text-muted-foreground">
{total > 0 ? ((item.value / total) * 100).toFixed(0) : 0}%
</span>
</div>
))}
</div>
</div>
)
}
function TextResponses({
questionId,
responses,
}: {
questionId: string
responses: FormResponse[]
}) {
const [showAll, setShowAll] = useState(false)
const textAnswers = useMemo(() => {
return responses
.map((r) => {
const answer = r.answers.find((a) => a.question_id === questionId)
return answer
? { text: answer.answer, date: r.submitted_at }
: null
})
.filter(Boolean) as { text: string; date: string }[]
}, [responses, questionId])
if (textAnswers.length === 0) {
return (
<p className="text-sm text-muted-foreground italic">No responses for this question.</p>
)
}
const visible = showAll ? textAnswers : textAnswers.slice(0, 5)
return (
<div>
<div className="flex flex-col gap-2">
{visible.map((item, i) => (
<div
key={i}
className="rounded-lg border border-border bg-background px-4 py-3"
>
<p className="text-sm text-foreground">{item.text}</p>
<p className="mt-1 text-xs text-muted-foreground">
{new Date(item.date).toLocaleString()}
</p>
</div>
))}
</div>
{textAnswers.length > 5 && (
<button
onClick={() => setShowAll((prev) => !prev)}
className="mt-3 text-sm font-medium text-primary hover:underline"
>
{showAll ? "Show less" : `Show all ${textAnswers.length} responses`}
</button>
)}
</div>
)
}
function IndividualTab({
form,
responses,
}: {
form: FormDetail
responses: FormResponse[]
}) {
const [selectedIndex, setSelectedIndex] = useState(0)
const sorted = useMemo(
() =>
[...responses].sort(
(a, b) =>
new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime()
),
[responses]
)
const current = sorted[selectedIndex]
if (!current) return null
return (
<div className="flex flex-col gap-6">
{/* Navigation */}
<div className="flex items-center justify-between rounded-xl border border-border bg-card p-4 shadow-sm">
<button
onClick={() => setSelectedIndex((i) => Math.max(0, i - 1))}
disabled={selectedIndex === 0}
className="rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:opacity-40"
>
Previous
</button>
<div className="text-center">
<p className="text-sm font-semibold text-foreground">
Response {selectedIndex + 1} of {sorted.length}
</p>
<p className="text-xs text-muted-foreground">
{new Date(current.submitted_at).toLocaleString()}
</p>
</div>
<button
onClick={() =>
setSelectedIndex((i) => Math.min(sorted.length - 1, i + 1))
}
disabled={selectedIndex === sorted.length - 1}
className="rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:opacity-40"
>
Next
</button>
</div>
{/* Answers */}
<div className="flex flex-col gap-4">
{form.questions.map((question, index) => {
const answer = current.answers.find(
(a) => a.question_id === question.id
)
return (
<div
key={question.id}
className="rounded-xl border border-border bg-card p-5 shadow-sm"
>
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
{index + 1}
</span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-foreground">
{question.title}
{question.required && (
<span className="ml-1 text-destructive">*</span>
)}
</h3>
<p className="mt-1 text-xs capitalize text-muted-foreground">
{question.type.replace("_", " ")}
</p>
{answer ? (
<div className="mt-3 rounded-lg border border-border bg-background px-4 py-2.5">
<p className="text-sm text-foreground">{answer.answer}</p>
</div>
) : (
<p className="mt-3 text-sm italic text-muted-foreground">
No answer (skipped)
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
)
}
+7
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react"
import { Link, useParams, useNavigate } from "react-router" import { Link, useParams, useNavigate } from "react-router"
import { import {
ArrowLeft, ArrowLeft,
BarChart3,
Calendar, Calendar,
FileText, FileText,
Lock, Lock,
@@ -142,6 +143,12 @@ export default function FormPreviewPage() {
{user && user.id === form.user_id && ( {user && user.id === form.user_id && (
<div className="mt-5 flex items-center gap-3 border-t border-border pt-5"> <div className="mt-5 flex items-center gap-3 border-t border-border pt-5">
<Link to={`/form/${id}/responses`}>
<FormButton type="button" variant="ghost" size="sm">
<BarChart3 className="h-4 w-4" />
Responses
</FormButton>
</Link>
<Link to={`/form/${id}/edit`}> <Link to={`/form/${id}/edit`}>
<FormButton type="button" variant="ghost" size="sm"> <FormButton type="button" variant="ghost" size="sm">
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
-6
View File
@@ -32,7 +32,6 @@ export default function SubmitFormPage() {
try { try {
const data = await getFormById(id!) const data = await getFormById(id!)
setForm(data) setForm(data)
// Initialize empty answers
const initial: Record<string, string> = {} const initial: Record<string, string> = {}
data.questions.forEach((q) => { data.questions.forEach((q) => {
initial[q.id] = "" initial[q.id] = ""
@@ -53,7 +52,6 @@ export default function SubmitFormPage() {
function updateAnswer(questionId: string, value: string) { function updateAnswer(questionId: string, value: string) {
setAnswers((prev) => ({ ...prev, [questionId]: value })) setAnswers((prev) => ({ ...prev, [questionId]: value }))
// Clear validation error on change
if (validationErrors[questionId]) { if (validationErrors[questionId]) {
setValidationErrors((prev) => { setValidationErrors((prev) => {
const next = { ...prev } const next = { ...prev }
@@ -82,7 +80,6 @@ export default function SubmitFormPage() {
if (!id || !form) return if (!id || !form) return
if (!validate()) { if (!validate()) {
// Scroll to first error
const firstErrorId = form.questions.find((q) => validationErrors[q.id] || (q.required && !answers[q.id]?.trim()))?.id const firstErrorId = form.questions.find((q) => validationErrors[q.id] || (q.required && !answers[q.id]?.trim()))?.id
if (firstErrorId) { if (firstErrorId) {
document.getElementById(`question-${firstErrorId}`)?.scrollIntoView({ behavior: "smooth", block: "center" }) document.getElementById(`question-${firstErrorId}`)?.scrollIntoView({ behavior: "smooth", block: "center" })
@@ -161,7 +158,6 @@ export default function SubmitFormPage() {
size="sm" size="sm"
onClick={() => { onClick={() => {
setSubmitted(false) setSubmitted(false)
// Reset answers
if (form) { if (form) {
const initial: Record<string, string> = {} const initial: Record<string, string> = {}
form.questions.forEach((q) => { initial[q.id] = "" }) form.questions.forEach((q) => { initial[q.id] = "" })
@@ -270,8 +266,6 @@ export default function SubmitFormPage() {
) )
} }
// ─── Question field component ────────────────────────────────────────────────
interface QuestionFieldProps { interface QuestionFieldProps {
question: Question question: Question
index: number index: number
+14 -1
View File
@@ -6,7 +6,7 @@ import {
isTokenExpired, isTokenExpired,
decodeJWT, decodeJWT,
} from "@/lib/auth" } from "@/lib/auth"
import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload, SubmitFormPayload } from "@/lib/types" import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload, SubmitFormPayload, FormResponse } from "@/lib/types"
export async function refreshAccessToken(): Promise<string | null> { export async function refreshAccessToken(): Promise<string | null> {
if (typeof window === "undefined") return null if (typeof window === "undefined") return null
@@ -232,4 +232,17 @@ export async function deleteForm(id: string): Promise<void> {
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
throw new Error(data?.message ?? "Failed to delete form") throw new Error(data?.message ?? "Failed to delete form")
} }
}
export async function getFormResponses(id: string): Promise<FormResponse[]> {
const res = await fetchWithAuth(`${import.meta.env.VITE_API_BASE_URL}/api/form/${id}/responses`)
if (!res.ok) {
if (res.status === 404) {
throw new Response("Form not found", { status: 404 })
}
throw new Error("Failed to fetch form responses")
}
return res.json()
} }
+13
View File
@@ -75,3 +75,16 @@ export interface SubmitFormAnswer {
export interface SubmitFormPayload { export interface SubmitFormPayload {
answers: SubmitFormAnswer[] answers: SubmitFormAnswer[]
} }
export interface FormResponseAnswer {
question_id: string
question_title: string
question_type: QuestionType
answer: string
}
export interface FormResponse {
id: string
submitted_at: string
answers: FormResponseAnswer[]
}
+389
View File
@@ -16,6 +16,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "7.12.0", "react-router": "7.12.0",
"recharts": "^3.7.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
@@ -2665,6 +2666,42 @@
"react-router": "7.12.0" "react-router": "7.12.0"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/node-fetch-server": { "node_modules/@remix-run/node-fetch-server": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.9.0.tgz", "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.9.0.tgz",
@@ -3022,6 +3059,18 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
@@ -3294,6 +3343,69 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3331,6 +3443,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -3709,6 +3827,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3727,6 +3966,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.1", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
@@ -3864,6 +4109,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -3931,6 +4186,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/exit-hook": { "node_modules/exit-hook": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
@@ -4255,12 +4516,31 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -5083,6 +5363,36 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -5211,6 +5521,57 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.58.0", "version": "4.58.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz",
@@ -5508,6 +5869,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5729,6 +6096,28 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+1
View File
@@ -19,6 +19,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "7.12.0", "react-router": "7.12.0",
"recharts": "^3.7.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },