mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
feat: init commit for dashboard
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActivityIcon,
|
||||
AreaChartIcon,
|
||||
BarChart3Icon,
|
||||
LineChartIcon,
|
||||
MapIcon,
|
||||
PieChartIcon,
|
||||
ScatterChart,
|
||||
SearchIcon,
|
||||
TableIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
// --- Mock Data ---
|
||||
|
||||
const DATASETS = ["FCC 2018 Survey", "users_channels", "messages", "new_members_daily", "users"];
|
||||
|
||||
const CHART_TYPES = [
|
||||
{ id: "area", name: "Area Chart", icon: AreaChartIcon },
|
||||
{ id: "bar", name: "Bar Chart", icon: BarChart3Icon },
|
||||
{ id: "line", name: "Line Chart", icon: LineChartIcon },
|
||||
{ id: "pie", name: "Pie Chart", icon: PieChartIcon },
|
||||
{ id: "table", name: "Table", icon: TableIcon },
|
||||
{ id: "big_number", name: "Big Number", icon: ActivityIcon }, // Fallback icon
|
||||
{ id: "scatter", name: "Scatter Plot", icon: ScatterChart }, // Fallback icon
|
||||
{ id: "map", name: "World Map", icon: MapIcon },
|
||||
];
|
||||
|
||||
export default function ChartBuilderPage() {
|
||||
const [selectedDataset, setSelectedDataset] = useState<string>("");
|
||||
const [selectedChartType, setSelectedChartType] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
|
||||
const filteredChartTypes = CHART_TYPES.filter((type) =>
|
||||
type.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Create a new chart</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8">
|
||||
{/* Option 1: Ask AI */}
|
||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<ActivityIcon className="text-brand-dark h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900">Ask your data</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Describe what you want to see and let AI build the chart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="e.g. How many users signed up last week?"
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
disabled={!userQuery}
|
||||
className="bg-brand-dark hover:bg-brand-dark/90"
|
||||
onClick={async () => {
|
||||
const response = await fetch("/api/analytics/query", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt: userQuery }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.data) {
|
||||
console.log("Chart Data:", data);
|
||||
alert("Chart generated! Check console for data. (Visualization implementation pending)");
|
||||
}
|
||||
}}>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Option 2: Build Manually */}
|
||||
<div className="space-y-8 opacity-75 transition-opacity hover:opacity-100">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
|
||||
1
|
||||
</span>
|
||||
<h2 className="font-medium text-gray-900">Choose a dataset</h2>
|
||||
</div>
|
||||
|
||||
<div className="ml-8 max-w-md">
|
||||
<Select value={selectedDataset} onValueChange={setSelectedDataset}>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="Choose a dataset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATASETS.map((ds) => (
|
||||
<SelectItem key={ds} value={ds}>
|
||||
{ds}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
|
||||
2
|
||||
</span>
|
||||
<h2 className="font-medium text-gray-900">Choose chart type</h2>
|
||||
</div>
|
||||
|
||||
<div className="ml-8 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="relative mb-4">
|
||||
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search all charts"
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{filteredChartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<div
|
||||
key={chart.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedChartType(chart.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setSelectedChartType(chart.id);
|
||||
}}
|
||||
className={cn(
|
||||
"focus:ring-brand-dark cursor-pointer rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
disabled={!selectedDataset || !selectedChartType}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
alert("Manual chart creation triggered");
|
||||
}}>
|
||||
Create Manually
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Edit2Icon, PlusIcon, SearchIcon, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { use } from "react";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useAnalysisStore } from "../store";
|
||||
|
||||
export default function ChartsListPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||
use(params);
|
||||
const { charts, dashboards } = useAnalysisStore();
|
||||
|
||||
// Helper to find dashboard names
|
||||
const getDashboardNames = (dashboardIds: string[]) => {
|
||||
return dashboardIds
|
||||
.map((id) => dashboards.find((d) => d.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header / Actions */}
|
||||
<div className="flex flex-col gap-4 border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Charts</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="chart-builder">
|
||||
<Button size="sm">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Chart
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input placeholder="Type a value" className="w-[300px] pl-9" />
|
||||
</div>
|
||||
{/* Filter Dropdowns */}
|
||||
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
|
||||
{["Type", "Dataset", "Owner", "Dashboard", "Favorite", "Certified", "Modified by"].map(
|
||||
(filter) => (
|
||||
<Button key={filter} variant="outline" className="whitespace-nowrap text-gray-500" size="sm">
|
||||
{filter} <span className="ml-2 text-xs">▼</span>
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Content */}
|
||||
<div className="flex-1 overflow-auto bg-gray-50 p-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table className="w-full text-left text-sm text-gray-600">
|
||||
<thead className="bg-gray-50 text-xs font-semibold uppercase text-gray-500">
|
||||
<tr>
|
||||
<th className="border-b border-gray-200 px-6 py-3">Name</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3">Type</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3">Dataset</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3">On dashboards</th>
|
||||
{/* Hiding owners to save space if needed, mirroring Superset compact view */}
|
||||
<th className="border-b border-gray-200 px-6 py-3">Last Modified</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{charts.map((chart) => (
|
||||
<tr key={chart.id} className="group transition-colors hover:bg-gray-50">
|
||||
<td className="text-brand-dark px-6 py-4 font-medium">
|
||||
<span className="cursor-pointer hover:underline">{chart.name}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-900">
|
||||
{chart.type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</td>
|
||||
<td className="text-brand-dark cursor-pointer px-6 py-4 hover:underline">
|
||||
{chart.dataset}
|
||||
</td>
|
||||
<td className="text-brand-dark px-6 py-4">
|
||||
{getDashboardNames(chart.dashboardIds) || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{formatDistanceToNow(new Date(chart.lastModified), { addSuffix: true })}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100">
|
||||
<TrashIcon className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100">
|
||||
<Edit2Icon className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{charts.length === 0 && <div className="p-12 text-center text-gray-500">No charts found.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { use } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, Bar as RechartsBar, XAxis } from "recharts";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useAnalysisStore } from "../../store";
|
||||
|
||||
// --- Mock Chart Components for Widget Content ---
|
||||
|
||||
const MockBarChart = () => {
|
||||
const data = [
|
||||
{ name: "Jan", value: 400 },
|
||||
{ name: "Feb", value: 300 },
|
||||
{ name: "Mar", value: 200 },
|
||||
{ name: "Apr", value: 278 },
|
||||
{ name: "May", value: 189 },
|
||||
{ name: "Jun", value: 239 },
|
||||
];
|
||||
return (
|
||||
<ChartContainer config={{ value: { label: "Value", color: "#6366f1" } }} className="h-full w-full">
|
||||
<BarChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="value" fill="var(--color-value)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const MockBigNumber = ({ title, value }: { title: string; value: string }) => (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="text-4xl font-bold text-gray-900">{value}</div>
|
||||
<div className="mt-2 text-sm text-gray-500">{title}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- Widget Wrapper ---
|
||||
|
||||
const DashboardWidget = ({
|
||||
title,
|
||||
children,
|
||||
error,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
error?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-sm border border-gray-200 bg-white shadow-sm ring-1 ring-black/5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{title}</h3>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-gray-400 hover:text-gray-600">
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Force refresh</DropdownMenuItem>
|
||||
<DropdownMenuItem>View as table</DropdownMenuItem>
|
||||
<DropdownMenuItem>Maximize</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="relative min-h-[300px] flex-1 p-4">
|
||||
{error ? (
|
||||
<div className="flex h-full w-full flex-col items-start justify-center rounded-md border border-red-100 bg-red-50 p-4">
|
||||
<div className="mb-1 flex items-center gap-2 font-semibold text-red-700">
|
||||
<div className="rounded-full bg-red-600 p-0.5">
|
||||
<span className="block h-3 w-3 text-center text-[10px] leading-3 text-white">✕</span>
|
||||
</div>
|
||||
Data error
|
||||
</div>
|
||||
<p className="text-xs text-red-600">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DashboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ environmentId: string; dashboardId: string }>;
|
||||
}) {
|
||||
const { dashboardId } = use(params);
|
||||
const { dashboards } = useAnalysisStore();
|
||||
|
||||
// Find dashboard
|
||||
// For the purpose of the demo/port, we might need to map the ID or just grab the first one if mock data logic is simple
|
||||
const dashboard = dashboards.find((d) => d.id === dashboardId) || dashboards[0]; // Fallback for demo flow
|
||||
|
||||
if (!dashboard) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Demo: Determine if empty based on ID or simple toggle mechanism
|
||||
// We'll fake an empty dashboard if ID suggests it or random chance for demo
|
||||
const isEmpty = dashboard.widgets.length === 0 && dashboard.id !== "d1" && dashboard.id !== "d2";
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-120px)] bg-gray-100 p-6">
|
||||
{/* Dashboard Header Context (Actions, filters) would go here */}
|
||||
<div className="mb-6 flex justify-end">
|
||||
<Button variant="outline" size="sm" className="text-brand-dark border-brand-dark/20 mr-2 bg-white">
|
||||
Draft
|
||||
</Button>
|
||||
<Button size="sm">Edit dashboard</Button>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
// Empty State
|
||||
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
|
||||
<div className="mb-4 rounded-full bg-gray-100 p-4">
|
||||
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" /> {/* Abstract File Icon */}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
|
||||
<p className="mt-2 max-w-sm text-center text-gray-500">
|
||||
There is currently no information to display. Add charts to build your dashboard.
|
||||
</p>
|
||||
<Link href="../chart-builder">
|
||||
<Button className="mt-6" variant="default">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Create Chart
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
// Grid Layout
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Row 1 */}
|
||||
<div className="col-span-12 md:col-span-3">
|
||||
<DashboardWidget
|
||||
title="Average NPS Score"
|
||||
error="Error: Datetime column not provided as part table configuration and is required by this type of chart">
|
||||
<MockBigNumber title="NPS" value="72" />
|
||||
</DashboardWidget>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 md:col-span-9">
|
||||
<DashboardWidget title="NPS Trend">
|
||||
<MockBarChart />
|
||||
</DashboardWidget>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
<DashboardWidget title="Total Responses">
|
||||
<MockBigNumber title="Total" value="1,293" />
|
||||
</DashboardWidget>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { MoreHorizontalIcon, PlusIcon, SearchIcon, StarIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useState } from "react";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useAnalysisStore } from "../store";
|
||||
|
||||
export default function DashboardsListPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||
use(params); // Unwrap params to satisfy Next.js, even if unused
|
||||
const { dashboards, addDashboard } = useAnalysisStore();
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredDashboards = dashboards.filter((dashboard) =>
|
||||
dashboard.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCreateDashboard = () => {
|
||||
const newId = `d-${Date.now()}`;
|
||||
addDashboard({
|
||||
id: newId,
|
||||
name: "New Dashboard",
|
||||
status: "draft",
|
||||
owners: [{ id: "u1", name: "Admin" }], // Mock owner
|
||||
lastModified: new Date().toISOString(),
|
||||
isFavorite: false,
|
||||
widgets: [],
|
||||
});
|
||||
router.push(`dashboard/${newId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header / Actions */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search dashboards"
|
||||
className="w-[300px] pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Filter Dropdowns - Visual Only for MVP port */}
|
||||
<Button variant="outline" className="text-gray-500">
|
||||
Owner <span className="ml-2 text-xs">▼</span>
|
||||
</Button>
|
||||
<Button variant="outline" className="text-gray-500">
|
||||
Status <span className="ml-2 text-xs">▼</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleCreateDashboard}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table Content */}
|
||||
<div className="flex-1 overflow-auto bg-gray-50 p-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table className="w-full text-left text-sm text-gray-600">
|
||||
<thead className="bg-gray-50 text-xs font-semibold uppercase text-gray-500">
|
||||
<tr>
|
||||
<th className="border-b border-gray-200 px-6 py-3">Name</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3">Status</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3">Owners</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3">Last Modified</th>
|
||||
<th className="border-b border-gray-200 px-6 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredDashboards.map((dashboard) => (
|
||||
<tr key={dashboard.id} className="group hover:bg-gray-50">
|
||||
<td className="px-6 py-4 font-medium text-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Star Icon - Active state simulation */}
|
||||
<StarIcon
|
||||
className={`h-4 w-4 ${dashboard.isFavorite ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`}
|
||||
/>
|
||||
<Link href={`dashboard/${dashboard.id}`}>
|
||||
<span className="cursor-pointer hover:underline">{dashboard.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
|
||||
{dashboard.status === "published" ? "Published" : "Draft"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">{dashboard.owners.map((u) => u.name).join(", ")}</td>
|
||||
<td className="px-6 py-4">
|
||||
{formatDistanceToNow(new Date(dashboard.lastModified), { addSuffix: true })}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100">
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-600">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredDashboards.length === 0 && (
|
||||
<div className="p-12 text-center text-gray-500">No dashboards found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { use } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const TABS = [
|
||||
{ name: "Dashboards", href: "dashboards" },
|
||||
{ name: "Charts", href: "charts" },
|
||||
];
|
||||
|
||||
export default function AnalysisLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ environmentId: string }>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { environmentId } = use(params);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="Analysis" />
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{TABS.map((tab) => {
|
||||
const href = `/environments/${environmentId}/analysis/${tab.href}`;
|
||||
// Check if pathname starts with the tab href to allow for sub-routes (like dashboard detail)
|
||||
// But for exact match on list pages, we can be more specific if needed.
|
||||
// For now, simple prefix check is good for "Charts" vs "Dashboards".
|
||||
// However, "dashboards/d1" should still highlight "Dashboards".
|
||||
const isActive = pathname?.includes(`/${tab.href}`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
href={href}
|
||||
className={cn(
|
||||
isActive
|
||||
? "border-brand-dark text-brand-dark"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium"
|
||||
)}>
|
||||
{tab.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="pt-6">{children}</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||
const { environmentId } = await params;
|
||||
if (!environmentId || environmentId === "undefined") {
|
||||
redirect("/");
|
||||
}
|
||||
redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type DashboardStatus = "published" | "draft";
|
||||
|
||||
export interface AnalysisUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: DashboardStatus;
|
||||
owners: AnalysisUser[];
|
||||
lastModified: string; // ISO Date string
|
||||
isFavorite: boolean;
|
||||
widgets: DashboardWidget[];
|
||||
}
|
||||
|
||||
export interface DashboardWidget {
|
||||
id: string;
|
||||
type: "chart" | "markdown" | "header" | "divider";
|
||||
title?: string;
|
||||
chartId?: string; // If type is chart
|
||||
layout: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type ChartType =
|
||||
| "area"
|
||||
| "bar"
|
||||
| "line"
|
||||
| "pie"
|
||||
| "big_number"
|
||||
| "big_number_total"
|
||||
| "table"
|
||||
| "funnel"
|
||||
| "map";
|
||||
|
||||
export interface Chart {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChartType;
|
||||
dataset: string;
|
||||
owners: AnalysisUser[];
|
||||
lastModified: string;
|
||||
dashboardIds: string[];
|
||||
config: Record<string, any>; // Flexible config for specific chart props
|
||||
}
|
||||
|
||||
interface AnalysisState {
|
||||
dashboards: Dashboard[];
|
||||
charts: Chart[];
|
||||
activeDashboard: Dashboard | null;
|
||||
layoutMode: "view" | "edit";
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setDashboards: (dashboards: Dashboard[]) => void;
|
||||
setCharts: (charts: Chart[]) => void;
|
||||
setActiveDashboard: (dashboard: Dashboard | null) => void;
|
||||
setLayoutMode: (mode: "view" | "edit") => void;
|
||||
addDashboard: (dashboard: Dashboard) => void;
|
||||
updateDashboard: (id: string, updates: Partial<Dashboard>) => void;
|
||||
}
|
||||
|
||||
// --- Mock Data ---
|
||||
|
||||
const MOCK_USERS: AnalysisUser[] = [
|
||||
{ id: "u1", name: "Admin User" },
|
||||
{ id: "u2", name: "Jane Doe" },
|
||||
];
|
||||
|
||||
const MOCK_CHARTS: Chart[] = [
|
||||
{
|
||||
id: "c1",
|
||||
name: "Gender",
|
||||
type: "pie",
|
||||
dataset: "FCC 2018 Survey",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 3).toISOString(), // 3 mins ago
|
||||
dashboardIds: ["d1"],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: "c2",
|
||||
name: "Cross Channel Relationship",
|
||||
type: "bar", // Using bar as approximation for chord if not available
|
||||
dataset: "users_channels-uzooNNtSRO",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 3).toISOString(),
|
||||
dashboardIds: ["d2"],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: "c3",
|
||||
name: "Weekly Messages",
|
||||
type: "line",
|
||||
dataset: "messages",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(), // 14 mins ago
|
||||
dashboardIds: ["d2"],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: "c4",
|
||||
name: "New Members per Month",
|
||||
type: "line",
|
||||
dataset: "new_members_daily",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
||||
dashboardIds: ["d2"],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: "c5",
|
||||
name: "Number of Members",
|
||||
type: "big_number",
|
||||
dataset: "users",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
||||
dashboardIds: ["d2"],
|
||||
config: {},
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_DASHBOARDS: Dashboard[] = [
|
||||
{
|
||||
id: "d1",
|
||||
name: "FCC New Coder Survey 2018",
|
||||
status: "published",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
||||
isFavorite: true,
|
||||
widgets: [],
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
name: "Slack Dashboard",
|
||||
status: "published",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
||||
isFavorite: true,
|
||||
widgets: [],
|
||||
},
|
||||
{
|
||||
id: "d3",
|
||||
name: "Sales Dashboard",
|
||||
status: "published",
|
||||
owners: [MOCK_USERS[0]],
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
||||
isFavorite: true,
|
||||
widgets: [],
|
||||
},
|
||||
];
|
||||
|
||||
// --- Store ---
|
||||
|
||||
export const useAnalysisStore = create<AnalysisState>((set) => ({
|
||||
dashboards: MOCK_DASHBOARDS,
|
||||
charts: MOCK_CHARTS,
|
||||
activeDashboard: null,
|
||||
layoutMode: "view",
|
||||
isLoading: false,
|
||||
|
||||
setDashboards: (dashboards) => set({ dashboards }),
|
||||
setCharts: (charts) => set({ charts }),
|
||||
setActiveDashboard: (activeDashboard) => set({ activeDashboard }),
|
||||
setLayoutMode: (layoutMode) => set({ layoutMode }),
|
||||
addDashboard: (dashboard) => set((state) => ({ dashboards: [...state.dashboards, dashboard] })),
|
||||
updateDashboard: (id, updates) =>
|
||||
set((state) => ({
|
||||
dashboards: state.dashboards.map((d) => (d.id === id ? { ...d, ...updates } : d)),
|
||||
})),
|
||||
}));
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PieChart,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
@@ -105,6 +106,13 @@ export const MainNavigation = ({
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/environments/${environment.id}/analysis`,
|
||||
icon: PieChart,
|
||||
isActive: pathname?.includes("/analysis"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
@@ -185,7 +193,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -11,6 +11,12 @@ const EnvLayout = async (props: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const { environmentId } = params;
|
||||
|
||||
if (environmentId === "undefined") {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const { children } = props;
|
||||
|
||||
// Check session first (required for userId)
|
||||
|
||||
@@ -106,6 +106,7 @@ checksums:
|
||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
|
||||
"analysis": "Analyse",
|
||||
"and": "und",
|
||||
"and_response_limit_of": "und Antwortlimit von",
|
||||
"anonymous": "Anonym",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||
"analysis": "Analysis",
|
||||
"and": "And",
|
||||
"and_response_limit_of": "and response limit of",
|
||||
"anonymous": "Anonymous",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||
"analysis": "Análisis",
|
||||
"and": "Y",
|
||||
"and_response_limit_of": "y límite de respuesta de",
|
||||
"anonymous": "Anónimo",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Autoriser",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "Et",
|
||||
"and_response_limit_of": "et limite de réponse de",
|
||||
"anonymous": "Anonyme",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "許可",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
|
||||
"analysis": "分析",
|
||||
"and": "および",
|
||||
"and_response_limit_of": "と回答数の上限",
|
||||
"anonymous": "匿名",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Toestaan",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "En",
|
||||
"and_response_limit_of": "en responslimiet van",
|
||||
"anonymous": "Anoniem",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anônimo",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anónimo",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Permite",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
|
||||
"analysis": "Analiză",
|
||||
"and": "Și",
|
||||
"and_response_limit_of": "și limită răspuns",
|
||||
"anonymous": "Anonim",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Разрешить",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
|
||||
"analysis": "Анализ",
|
||||
"and": "и",
|
||||
"and_response_limit_of": "и лимит ответов",
|
||||
"anonymous": "Аноним",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "Tillåt",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
|
||||
"analysis": "Analys",
|
||||
"and": "Och",
|
||||
"and_response_limit_of": "och svarsgräns på",
|
||||
"anonymous": "Anonym",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "允许",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
|
||||
"analysis": "分析",
|
||||
"and": "和",
|
||||
"and_response_limit_of": "和 响应限制",
|
||||
"anonymous": "匿名",
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
"allow": "允許",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
|
||||
"analysis": "分析",
|
||||
"and": "且",
|
||||
"and_response_limit_of": "且回應上限為",
|
||||
"anonymous": "匿名",
|
||||
|
||||
303
apps/web/modules/ui/components/chart.tsx
Normal file
303
apps/web/modules/ui/components/chart.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import { ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Format: { THEME_NAME: CSS_VARIABLE }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof ResponsiveContainer>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "ChartContainer";
|
||||
|
||||
const ChartTooltip = Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||
@@ -124,6 +124,7 @@
|
||||
"react-i18next": "15.7.3",
|
||||
"react-turnstile": "1.1.4",
|
||||
"react-use": "17.6.0",
|
||||
"recharts": "3.7.0",
|
||||
"redis": "4.7.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"server-only": "0.0.1",
|
||||
@@ -137,7 +138,8 @@
|
||||
"webpack": "5.99.8",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "3.24.4",
|
||||
"zod-openapi": "4.2.4"
|
||||
"zod-openapi": "4.2.4",
|
||||
"zustand": "5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
15
cube/cube.js
Normal file
15
cube/cube.js
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
// Validate that the auth payload is present
|
||||
checkSqlAuth: (req, auth) => {
|
||||
// In dev mode with API secret, auth should be populated
|
||||
if (!auth) {
|
||||
// throw new Error('Authentication required');
|
||||
}
|
||||
},
|
||||
|
||||
// Rewrite queries based on security context (RLS)
|
||||
queryRewrite: (query, { securityContext }) => {
|
||||
console.log('Query Security Context:', securityContext);
|
||||
return query;
|
||||
},
|
||||
};
|
||||
44
cube/schema/FeedbackRecords.js
Normal file
44
cube/schema/FeedbackRecords.js
Normal file
@@ -0,0 +1,44 @@
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `
|
||||
SELECT
|
||||
id,
|
||||
created_at as collected_at,
|
||||
(data->>'q18782jji4swm64miro9ei7e')::numeric as value_number
|
||||
FROM "Response"
|
||||
WHERE "surveyId" = 'clseedsurveykitchen00'
|
||||
AND data->>'q18782jji4swm64miro9ei7e' IS NOT NULL
|
||||
`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`
|
||||
},
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`
|
||||
}
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`
|
||||
},
|
||||
value: {
|
||||
sql: `value_number`,
|
||||
type: `number`
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -36,6 +36,24 @@ services:
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
|
||||
cube:
|
||||
image: cubejs/cube:latest
|
||||
ports:
|
||||
- 4000:4000
|
||||
- 4001:4001
|
||||
environment:
|
||||
- CUBEJS_DB_HOST=postgres
|
||||
- CUBEJS_DB_PORT=5432
|
||||
- CUBEJS_DB_NAME=postgres
|
||||
- CUBEJS_DB_USER=postgres
|
||||
- CUBEJS_DB_PASS=postgres
|
||||
- CUBEJS_DB_TYPE=postgres
|
||||
- CUBEJS_API_SECRET=your-secret-key-change-in-production
|
||||
- CUBEJS_DEV_MODE=true
|
||||
volumes:
|
||||
- ./cube/cube.js:/cube/conf/cube.js
|
||||
- ./cube/schema:/cube/conf/model
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
|
||||
318
pnpm-lock.yaml
generated
318
pnpm-lock.yaml
generated
@@ -437,6 +437,9 @@ importers:
|
||||
react-use:
|
||||
specifier: 17.6.0
|
||||
version: 17.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
recharts:
|
||||
specifier: 3.7.0
|
||||
version: 3.7.0(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
|
||||
redis:
|
||||
specifier: 4.7.0
|
||||
version: 4.7.0
|
||||
@@ -479,9 +482,9 @@ importers:
|
||||
zod-openapi:
|
||||
specifier: 4.2.4
|
||||
version: 4.2.4(zod@3.24.4)
|
||||
zod-to-json-schema:
|
||||
specifier: ^3.23.5
|
||||
version: 3.25.1(zod@3.24.4)
|
||||
zustand:
|
||||
specifier: 5.0.10
|
||||
version: 5.0.10(@types/react@19.2.1)(immer@11.1.3)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
|
||||
devDependencies:
|
||||
'@formbricks/config-typescript':
|
||||
specifier: workspace:*
|
||||
@@ -4153,6 +4156,17 @@ packages:
|
||||
peerDependencies:
|
||||
'@redis/client': ^5.8.1
|
||||
|
||||
'@reduxjs/toolkit@2.11.2':
|
||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||
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
|
||||
|
||||
'@resvg/resvg-wasm@2.4.0':
|
||||
resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -5190,6 +5204,33 @@ packages:
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
@@ -5315,6 +5356,9 @@ packages:
|
||||
'@types/ungap__structured-clone@1.2.0':
|
||||
resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/uuid@9.0.8':
|
||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||
|
||||
@@ -6581,6 +6625,50 @@ packages:
|
||||
csv-parse@5.6.0:
|
||||
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
damerau-levenshtein@1.0.8:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
@@ -6657,6 +6745,9 @@ packages:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -6959,6 +7050,9 @@ packages:
|
||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.44.0:
|
||||
resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==}
|
||||
|
||||
esbuild@0.23.1:
|
||||
resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7736,6 +7830,12 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.3:
|
||||
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -7779,6 +7879,10 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
intl-messageformat@10.7.18:
|
||||
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
|
||||
|
||||
@@ -9543,6 +9647,18 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.14.2:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -9645,6 +9761,14 @@ packages:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
recharts@3.7.0:
|
||||
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
|
||||
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
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -9656,6 +9780,14 @@ packages:
|
||||
resolution: {integrity: sha512-RZjBKYX/qFF809x6vDcE5VA6L3MmiuT+BkbXbIyyyeU0lPD47V4z8qTzN+Z/kKFwpojwCItOfaItYuAjNs8pTQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
@@ -9690,6 +9822,9 @@ packages:
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resize-observer-polyfill@1.5.1:
|
||||
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
|
||||
|
||||
@@ -10769,6 +10904,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-node@3.1.3:
|
||||
resolution: {integrity: sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -11176,14 +11314,27 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.21.4
|
||||
|
||||
zod-to-json-schema@3.25.1:
|
||||
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
zod@3.24.4:
|
||||
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
|
||||
|
||||
zustand@5.0.10:
|
||||
resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=18.0.0'
|
||||
use-sync-external-store: '>=1.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
use-sync-external-store:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@acemir/cssom@0.9.29': {}
|
||||
@@ -15534,6 +15685,18 @@ snapshots:
|
||||
dependencies:
|
||||
'@redis/client': 5.8.1
|
||||
|
||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.1)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.3
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
react-redux: 9.2.0(@types/react@19.2.1)(react@19.2.3)(redux@5.0.1)
|
||||
|
||||
'@resvg/resvg-wasm@2.4.0': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||
@@ -16713,6 +16876,30 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.15.18
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/doctrine@0.0.9': {}
|
||||
@@ -16852,6 +17039,8 @@ snapshots:
|
||||
|
||||
'@types/ungap__structured-clone@1.2.0': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/uuid@9.0.8': {}
|
||||
|
||||
'@types/webidl-conversions@7.0.3': {}
|
||||
@@ -18343,6 +18532,44 @@ snapshots:
|
||||
|
||||
csv-parse@5.6.0: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
damerau-levenshtein@1.0.8: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
@@ -18400,6 +18627,8 @@ snapshots:
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
@@ -18753,6 +18982,8 @@ snapshots:
|
||||
is-date-object: 1.1.0
|
||||
is-symbol: 1.1.1
|
||||
|
||||
es-toolkit@1.44.0: {}
|
||||
|
||||
esbuild@0.23.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.23.1
|
||||
@@ -19767,6 +19998,10 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.3: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -19809,6 +20044,8 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
intl-messageformat@10.7.18:
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
@@ -21590,6 +21827,15 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.1)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.1
|
||||
redux: 5.0.1
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-refresh@0.18.0: {}
|
||||
@@ -21738,6 +21984,26 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
recharts@3.7.0(@types/react@19.2.1)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.1)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.44.0
|
||||
eventemitter3: 5.0.1
|
||||
immer: 10.2.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.2.0(@types/react@19.2.1)(react@19.2.3)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
@@ -21760,6 +22026,12 @@ snapshots:
|
||||
'@redis/search': 5.8.1(@redis/client@5.8.1)
|
||||
'@redis/time-series': 5.8.1(@redis/client@5.8.1)
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
@@ -21802,6 +22074,8 @@ snapshots:
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resize-observer-polyfill@1.5.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@@ -23069,6 +23343,23 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@3.1.3(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -23605,8 +23896,11 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.24.4
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@3.24.4):
|
||||
dependencies:
|
||||
zod: 3.24.4
|
||||
|
||||
zod@3.24.4: {}
|
||||
|
||||
zustand@5.0.10(@types/react@19.2.1)(immer@11.1.3)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.1
|
||||
immer: 11.1.3
|
||||
react: 19.2.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
|
||||
Reference in New Issue
Block a user