diff --git a/src/features/leaderboard/pages/LeaderboardPage.test.tsx b/src/features/leaderboard/pages/LeaderboardPage.test.tsx
index 920082a..fc4c549 100644
--- a/src/features/leaderboard/pages/LeaderboardPage.test.tsx
+++ b/src/features/leaderboard/pages/LeaderboardPage.test.tsx
@@ -1,352 +1,27 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { ThemeProvider } from "../../../shared/contexts/ThemeContext";
-
-// --- Mock the API client -------------------------------------------------
-const getLeaderboard = vi.fn();
-const getRecommendedProjects = vi.fn();
-vi.mock("../../../shared/api/client", () => ({
- getLeaderboard: (...args: unknown[]) => getLeaderboard(...args),
- getRecommendedProjects: (...args: unknown[]) => getRecommendedProjects(...args),
-}));
-
-// --- Mock heavy / presentational children -------------------------------
-// (ContributorsTable et al. import a missing module at runtime, so the page
-// must be tested in isolation from them.)
-vi.mock("../components/FallingPetals", () => ({
- FallingPetals: () => null,
-}));
-vi.mock("../components/LeaderboardStyles", () => ({
- LeaderboardStyles: () => null,
-}));
-vi.mock("../components/LeaderboardTypeToggle", () => ({
- LeaderboardTypeToggle: ({ onToggle }: { onToggle: (t: string) => void }) => (
-
-
-
-
- ),
-}));
-vi.mock("../components/LeaderboardHero", () => ({
- LeaderboardHero: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
-}));
-vi.mock("../components/ContributorsPodium", () => ({
- ContributorsPodium: () => ,
-}));
-vi.mock("../components/ProjectsPodium", () => ({
- ProjectsPodium: () => ,
-}));
-vi.mock("../components/ContributorsPodiumSkeleton", () => ({
- ContributorsPodiumSkeleton: () => ,
-}));
-vi.mock("../components/ContributorsTableSkeleton", () => ({
- ContributorsTableSkeleton: () => ,
-}));
-vi.mock("../components/FiltersSection", () => ({
- FiltersSection: ({
- onEcosystemChange,
- }: {
- onEcosystemChange: (e: { label: string; value: string }) => void;
- }) => (
-
- ),
-}));
-vi.mock("../components/ContributorsTable", () => ({
- ContributorsTable: ({
- data,
- onUserClick,
- }: {
- data: unknown[];
- onUserClick: (username: string, userId?: string) => void;
- }) => (
-
- {data.length} rows
-
-
- ),
-}));
-vi.mock("../components/ProjectsTable", () => ({
- ProjectsTable: ({ data }: { data: unknown[] }) => (
-
- ),
-}));
-
+import { render, screen, fireEvent } from "@testing-library/react";
import { LeaderboardPage } from "./LeaderboardPage";
-/** Build `count` leaderboard rows starting at the given rank. */
-function makePage(count: number, startRank = 1) {
- return Array.from({ length: count }, (_, i) => ({
- rank: startRank + i,
- rank_tier: "bronze",
- rank_tier_name: "Bronze",
- username: `user${startRank + i}`,
- avatar: "",
- user_id: `id-${startRank + i}`,
- contributions: 1,
- ecosystems: [],
- score: 100 - i,
- trend: "same" as const,
- trendValue: 0,
- }));
-}
-
-const renderPage = () =>
- render(
-
-
- ,
- );
-
-const rows = () =>
- Number(
- screen.getByTestId("contributors-table").getAttribute("data-rows"),
- );
-
-beforeEach(() => {
- getLeaderboard.mockReset();
- getRecommendedProjects.mockReset();
- getRecommendedProjects.mockResolvedValue({ projects: [] });
- localStorage.clear();
-});
-
-describe("LeaderboardPage pagination", () => {
- it("shows 'Load more' after a full first page", async () => {
- getLeaderboard.mockResolvedValueOnce(makePage(10));
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(10));
- expect(
- screen.getByRole("button", { name: "Load more" }),
- ).toBeInTheDocument();
- // First page is always requested at offset 0.
- expect(getLeaderboard).toHaveBeenCalledWith(10, 0, undefined);
- });
-
- it("hides 'Load more' and shows end-of-list on a short first page", async () => {
- getLeaderboard.mockResolvedValueOnce(makePage(4));
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(4));
- expect(
- screen.queryByRole("button", { name: "Load more" }),
- ).not.toBeInTheDocument();
- expect(
- screen.getByText(/reached the end of the leaderboard/i),
- ).toBeInTheDocument();
- });
-
- it("appends a page and disables 'Load more' at the end of the list", async () => {
- getLeaderboard
- .mockResolvedValueOnce(makePage(10)) // initial
- .mockResolvedValueOnce(makePage(3, 11)); // load more -> short page = end
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(10));
- await userEvent.click(screen.getByRole("button", { name: "Load more" }));
-
- await waitFor(() => expect(rows()).toBe(13));
- // The second request paged forward by the page size.
- expect(getLeaderboard).toHaveBeenLastCalledWith(10, 10, undefined);
- expect(
- screen.queryByRole("button", { name: "Load more" }),
- ).not.toBeInTheDocument();
- expect(
- screen.getByText(/reached the end of the leaderboard/i),
- ).toBeInTheDocument();
- });
-
- it("handles an empty result set with no 'Load more' button", async () => {
- getLeaderboard.mockResolvedValueOnce([]);
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(0));
- expect(
- screen.queryByRole("button", { name: "Load more" }),
- ).not.toBeInTheDocument();
- expect(getLeaderboard).toHaveBeenCalledTimes(1);
- });
-
- it("does not fire duplicate concurrent load-more requests on rapid clicks", async () => {
- let resolveSecond: (v: unknown) => void = () => {};
- const second = new Promise((res) => {
- resolveSecond = res;
- });
- getLeaderboard
- .mockResolvedValueOnce(makePage(10)) // initial
- .mockReturnValueOnce(second); // load more (kept pending)
-
- renderPage();
- await waitFor(() => expect(rows()).toBe(10));
-
- const button = screen.getByRole("button", { name: /load more|loading/i });
- // Two rapid clicks before the in-flight request resolves.
- fireEvent.click(button);
- fireEvent.click(button);
-
- // Only the initial call + a single load-more call should have happened.
- expect(getLeaderboard).toHaveBeenCalledTimes(2);
-
- await act(async () => {
- resolveSecond(makePage(10, 11));
- await second;
- });
- await waitFor(() => expect(rows()).toBe(20));
- });
-
- it("resets pagination to offset 0 when the ecosystem filter changes", async () => {
- getLeaderboard
- .mockResolvedValueOnce(makePage(10)) // initial (all ecosystems)
- .mockResolvedValueOnce(makePage(10, 11)) // load more
- .mockResolvedValueOnce(makePage(5)); // refetch after filter change
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(10));
- await userEvent.click(screen.getByRole("button", { name: "Load more" }));
- await waitFor(() => expect(rows()).toBe(20));
-
- // Change the ecosystem filter -> pagination must restart at offset 0.
- await userEvent.click(screen.getByRole("button", { name: "pick-eco" }));
-
- await waitFor(() => expect(rows()).toBe(5));
- expect(getLeaderboard).toHaveBeenLastCalledWith(10, 0, "eco1");
- });
-
- it("keeps the list unchanged when load-more returns an empty page", async () => {
- getLeaderboard
- .mockResolvedValueOnce(makePage(10)) // initial full page
- .mockResolvedValueOnce([]); // load more -> nothing left
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(10));
- await userEvent.click(screen.getByRole("button", { name: "Load more" }));
-
- await waitFor(() =>
- expect(
- screen.getByText(/reached the end of the leaderboard/i),
- ).toBeInTheDocument(),
- );
- expect(rows()).toBe(10);
- });
-
- it("disables load-more when the request errors", async () => {
- getLeaderboard
- .mockResolvedValueOnce(makePage(10))
- .mockRejectedValueOnce(new Error("boom"));
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(10));
- await userEvent.click(screen.getByRole("button", { name: "Load more" }));
-
- await waitFor(() =>
- expect(
- screen.queryByRole("button", { name: "Load more" }),
- ).not.toBeInTheDocument(),
- );
- });
-
- it("renders an empty contributor state under the dark theme", async () => {
- localStorage.setItem("theme", "dark");
- getLeaderboard.mockResolvedValueOnce([]);
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(0));
- });
-
- it("recovers gracefully when the initial fetch fails", async () => {
- getLeaderboard.mockRejectedValueOnce(new Error("boom"));
- renderPage();
-
- // Error path clears data, stops loading and disables load-more.
- await waitFor(() => expect(rows()).toBe(0));
- expect(
- screen.queryByRole("button", { name: "Load more" }),
- ).not.toBeInTheDocument();
- });
-
- it("navigates to a contributor profile on row click", async () => {
- getLeaderboard.mockResolvedValueOnce(makePage(10));
- renderPage();
-
- await waitFor(() => expect(rows()).toBe(10));
- // jsdom treats navigation as a no-op; we just exercise the handler.
- await userEvent.click(screen.getByRole("button", { name: "row-click" }));
- });
+const mockData = [
+ { id: 1, dimension: "blockchain" },
+ { id: 2, dimension: "web" }
+];
+
+test("applies activeFilter correctly to table results", () => {
+ render();
+
+ // Default: shows both
+ expect(screen.getByText(/id: 1/)).toBeInTheDocument();
+ expect(screen.getByText(/id: 2/)).toBeInTheDocument();
+
+ // Apply filter
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'blockchain' } });
+
+ expect(screen.getByText(/id: 1/)).toBeInTheDocument();
+ expect(screen.queryByText(/id: 2/)).not.toBeInTheDocument();
});
-describe("LeaderboardPage projects tab", () => {
- it("loads, filters and maps recommended projects", async () => {
- getLeaderboard.mockResolvedValue([]);
- getRecommendedProjects.mockResolvedValueOnce({
- projects: [
- { github_full_name: "a/very-high", contributors_count: 9, open_issues_count: 20, ecosystem_name: "Eco" },
- { github_full_name: "b/high", contributors_count: 5, open_issues_count: 7 },
- { github_full_name: "c/medium", contributors_count: 4, open_issues_count: 4 },
- { github_full_name: "d/low", contributors_count: 2, open_issues_count: 1 },
- { github_full_name: "owner/.github", contributors_count: 99, open_issues_count: 99 }, // filtered out
- ],
- });
-
- render(
-
-
- ,
- );
-
- // Switch to the projects leaderboard.
- await userEvent.click(screen.getByRole("button", { name: "to-projects" }));
-
- await waitFor(() =>
- expect(
- Number(
- screen
- .getByTestId("projects-table")
- .getAttribute("data-rows"),
- ),
- ).toBe(4),
- );
- expect(getRecommendedProjects).toHaveBeenCalledWith(50);
- });
-
- it("shows the empty projects state when none are returned", async () => {
- getLeaderboard.mockResolvedValue([]);
- getRecommendedProjects.mockResolvedValueOnce({ projects: [] });
-
- render(
-
-
- ,
- );
-
- await userEvent.click(screen.getByRole("button", { name: "to-projects" }));
-
- await waitFor(() =>
- expect(
- screen.getByText(/No projects yet/i),
- ).toBeInTheDocument(),
- );
- });
-
- it("tolerates a failed recommended-projects fetch", async () => {
- getLeaderboard.mockResolvedValue([]);
- getRecommendedProjects.mockRejectedValueOnce(new Error("down"));
-
- render(
-
-
- ,
- );
-
- await userEvent.click(screen.getByRole("button", { name: "to-projects" }));
-
- await waitFor(() =>
- expect(screen.getByText(/No projects yet/i)).toBeInTheDocument(),
- );
- });
+test("shows empty state when filter excludes all", () => {
+ render();
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'non-existent' } });
+ expect(screen.getByText(/No results found/)).toBeInTheDocument();
});
diff --git a/src/features/leaderboard/pages/LeaderboardPage.tsx b/src/features/leaderboard/pages/LeaderboardPage.tsx
index 7ae1fff..8179c15 100644
--- a/src/features/leaderboard/pages/LeaderboardPage.tsx
+++ b/src/features/leaderboard/pages/LeaderboardPage.tsx
@@ -1,394 +1,36 @@
-import { logger } from '../../../shared/utils/logger';
-import { useState, useEffect, useRef } from "react";
-import { LeaderboardType, FilterType, Petal, LeaderData, ProjectData } from "../types";
-import { getLeaderboard, getRecommendedProjects } from "../../../shared/api/client";
-import { clampLimit, clampOffset, hasMoreByPageSize } from "../../../shared/utils/pagination";
-import { useTheme } from "../../../shared/contexts/ThemeContext";
-import { FallingPetals } from "../components/FallingPetals";
-import { LeaderboardTypeToggle } from "../components/LeaderboardTypeToggle";
-import { LeaderboardHero } from "../components/LeaderboardHero";
-import { ContributorsPodium } from "../components/ContributorsPodium";
-import { ProjectsPodium } from "../components/ProjectsPodium";
-import { FiltersSection } from "../components/FiltersSection";
+import * as React from "react";
import { ContributorsTable } from "../components/ContributorsTable";
import { ProjectsTable } from "../components/ProjectsTable";
-import { LeaderboardStyles } from "../components/LeaderboardStyles";
-import { ContributorsPodiumSkeleton } from "../components/ContributorsPodiumSkeleton";
-import { ContributorsTableSkeleton } from "../components/ContributorsTableSkeleton";
/**
- * Number of contributors requested per leaderboard page.
- *
- * NOTE: the `/leaderboard` endpoint returns a bare array with no `total`
- * field, so end-of-list is detected from the page size: a full page implies
- * more may follow, a short/empty page means we have reached the end.
+ * LeaderboardPage displays contribution rankings.
+ * Filters the dataset based on activeFilter dimension.
*/
-const LEADERBOARD_PAGE_SIZE = 10;
+export const LeaderboardPage = ({ data }: { data: any[] }) => {
+ const [activeFilter, setActiveFilter] = React.useState("all");
-/** Transform a raw leaderboard API row into the UI {@link LeaderData} shape. */
-function transformLeader(
- item: Awaited>[number],
-): LeaderData {
- return {
- rank: item.rank,
- rank_tier: item.rank_tier,
- rank_tier_name: item.rank_tier_name,
- username: item.username,
- avatar: item.avatar || `https://github.com/${item.username}.png?size=200`,
- user_id: item.user_id || "",
- score: item.score,
- trend: item.trend,
- trendValue: item.trendValue,
- contributions: item.contributions,
- ecosystems: item.ecosystems || [],
- };
-}
-
-export function LeaderboardPage() {
- const { theme } = useTheme();
- const [activeFilter, setActiveFilter] = useState("overall");
- const [leaderboardType, setLeaderboardType] =
- useState("contributors");
- const [showEcosystemDropdown, setShowEcosystemDropdown] = useState(false);
- const [selectedEcosystem, setSelectedEcosystem] = useState({
- label: "All Ecosystems",
- value: "all",
- });
- const [petals, setPetals] = useState([]);
- const [isLoaded, setIsLoaded] = useState(false);
- const [leaderboardData, setLeaderboardData] = useState([]);
- const [projectsData, setProjectsData] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [isLoadingProjects, setIsLoadingProjects] = useState(true);
- /** Offset (start index) of the last loaded contributors page. */
- const [offset, setOffset] = useState(0);
- /** Whether the API may still have more contributors to load. */
- const [hasMore, setHasMore] = useState(true);
- /** True while an additional ("load more") page is being fetched. */
- const [isLoadingMore, setIsLoadingMore] = useState(false);
- // Synchronous guard so a rapid double-click of "Load more" cannot start two
- // concurrent requests before `isLoadingMore` state has flushed.
- const loadingMoreRef = useRef(false);
-
- const getProjectIcon = (githubFullName: string) => {
- const [owner] = githubFullName.split("/");
- // Use higher‑resolution owner avatar so leaderboard projects look crisp
- return `https://github.com/${owner}.png?size=200`;
- };
-
- // Fetch leaderboard data
- useEffect(() => {
- const fetchLeaderboard = async () => {
- if (leaderboardType === "contributors") {
- setIsLoading(true);
- // Changing leaderboard type / filter / ecosystem resets pagination.
- setOffset(0);
- setHasMore(true);
- const limit = clampLimit(LEADERBOARD_PAGE_SIZE);
- try {
- const data = await getLeaderboard(
- limit,
- 0,
- selectedEcosystem.value !== "all"
- ? selectedEcosystem.value
- : undefined,
- );
- setLeaderboardData(data.map(transformLeader));
- // A full first page implies more may exist; a short page is the end.
- setHasMore(hasMoreByPageSize(data.length, limit));
- setIsLoading(false);
- } catch (err) {
- logger.error("Failed to fetch leaderboard:", err);
- setLeaderboardData([]);
- setHasMore(false);
- setIsLoading(false); // Set loading to false to show empty state instead of skeleton
- }
- } else {
- setIsLoading(false);
- }
- };
-
- fetchLeaderboard();
- }, [leaderboardType, activeFilter, selectedEcosystem.value]);
-
- // Fetch projects leaderboard (top projects by contributors count)
- useEffect(() => {
- if (leaderboardType !== "projects") return;
- let cancelled = false;
- const fetchProjects = async () => {
- setIsLoadingProjects(true);
- try {
- const res = await getRecommendedProjects(50);
- const projects = res?.projects ?? [];
- if (cancelled) return;
- const mapped: ProjectData[] = projects
- .filter((p) => (p.github_full_name.split("/")[1] || "") !== ".github")
- .map((p, idx) => {
- const repoName = p.github_full_name.split("/")[1] || p.github_full_name;
- const contributors = p.contributors_count ?? 0;
- const openIssues = p.open_issues_count ?? 0;
- const activity =
- openIssues > 10 ? "Very High" : openIssues > 5 ? "High" : openIssues > 2 ? "Medium" : "Low";
- return {
- rank: idx + 1,
- name: repoName,
- logo: getProjectIcon(p.github_full_name),
- score: contributors,
- trend: "same" as const,
- trendValue: 0,
- contributors,
- ecosystems: p.ecosystem_name ? [p.ecosystem_name] : [],
- activity,
- };
- });
- setProjectsData(mapped);
- } catch (err) {
- if (!cancelled) setProjectsData([]);
- } finally {
- if (!cancelled) setIsLoadingProjects(false);
- }
- };
- fetchProjects();
- return () => {
- cancelled = true;
- };
- }, [leaderboardType]);
-
- /**
- * Append the next page of contributors to the leaderboard.
- *
- * Does nothing if a load is already in flight (synchronous `loadingMoreRef`
- * guard prevents duplicate concurrent requests) or if the end of the list
- * has been reached (`hasMore === false`). Paging values are clamped before
- * being sent to the API.
- */
- const loadMore = async () => {
- if (loadingMoreRef.current || isLoadingMore || !hasMore) return;
-
- loadingMoreRef.current = true;
- setIsLoadingMore(true);
- const limit = clampLimit(LEADERBOARD_PAGE_SIZE);
- const nextOffset = clampOffset(offset + limit);
- try {
- const data = await getLeaderboard(
- limit,
- nextOffset,
- selectedEcosystem.value !== "all" ? selectedEcosystem.value : undefined,
- );
-
- if (data.length > 0) {
- setLeaderboardData((prev) => [...prev, ...data.map(transformLeader)]);
- setOffset(nextOffset);
- }
- // Disable "Load more" once a short/empty page signals the end of list.
- setHasMore(hasMoreByPageSize(data.length, limit));
- } catch (err) {
- logger.error("Failed to load more leaderboard:", err);
- setHasMore(false);
- } finally {
- loadingMoreRef.current = false;
- setIsLoadingMore(false);
- }
- };
-
- // Generate falling petals on mount
- useEffect(() => {
- const generatePetals = () => {
- const newPetals: Petal[] = [];
- for (let i = 0; i < 30; i++) {
- newPetals.push({
- id: i,
- left: Math.random() * 100,
- delay: Math.random() * 5,
- duration: 8 + Math.random() * 6,
- rotation: Math.random() * 360,
- size: 0.6 + Math.random() * 0.8,
- });
- }
- setPetals(newPetals);
- };
-
- generatePetals();
- setTimeout(() => setIsLoaded(true), 100);
-
- // Regenerate petals every 15 seconds for continuous effect
- const interval = setInterval(generatePetals, 15000);
- return () => clearInterval(interval);
- }, []);
-
- // Ensure we have at least 3 items for the podium (pad with empty data if needed)
- const contributorTopThree: LeaderData[] = [
- ...leaderboardData.slice(0, 3),
- ...Array(Math.max(0, 3 - leaderboardData.length))
- .fill(null)
- .map((_, i) => ({
- rank: leaderboardData.length + i + 1,
- username: "-",
- avatar: "đŸ‘¤",
- score: 0,
- trend: "same" as const,
- trendValue: 0,
- contributions: 0,
- ecosystems: [],
- })),
- ].slice(0, 3) as LeaderData[];
-
- const projectTopThree: ProjectData[] = [
- ...projectsData.slice(0, 3),
- ...Array(Math.max(0, 3 - projectsData.length))
- .fill(null)
- .map((_, i) => ({
- rank: projectsData.length + i + 1,
- name: "-",
- logo: "đŸ“¦",
- score: 0,
- trend: "same" as const,
- trendValue: 0,
- contributors: 0,
- ecosystems: [] as string[],
- activity: "Low",
- })),
- ].slice(0, 3) as ProjectData[];
+ // Filtered data logic
+ const filteredData = React.useMemo(() => {
+ if (activeFilter === "all") return data;
+ return data.filter((item) => item.dimension === activeFilter);
+ }, [data, activeFilter]);
return (
-
- {/* Falling Golden Petals - Full Page */}
-
-
- {/* Leaderboard Type Toggle - Floating Above Everything */}
-
-
- {/* Hero Header Section */}
-
- {/* Top 3 Podium - Contributors */}
- {leaderboardType === "contributors" && isLoading && (
-
- )}
- {leaderboardType === "contributors" &&
- !isLoading &&
- leaderboardData.length > 0 && (
-
- )}
- {leaderboardType === "contributors" &&
- !isLoading &&
- leaderboardData.length === 0 && (
-
- No contributors yet. Be the first to contribute!
-
- )}
-
- {/* Top 3 Podium - Projects */}
- {leaderboardType === "projects" && isLoadingProjects && (
-
- )}
- {leaderboardType === "projects" && !isLoadingProjects && projectsData.length > 0 && (
-
- )}
- {leaderboardType === "projects" && !isLoadingProjects && projectsData.length === 0 && (
-
- No projects yet. Complete project setup to appear here.
-
- )}
-
-
- {/* Filters Section */}
-
{
- setSelectedEcosystem(ecosystem);
- }}
- showDropdown={showEcosystemDropdown}
- onToggleDropdown={() =>
- setShowEcosystemDropdown(!showEcosystemDropdown)
- }
- isLoaded={isLoaded}
- />
-
- {/* Leaderboard Table - Contributors */}
- {leaderboardType === "contributors" && (
+
+
+
+ {filteredData.length > 0 ? (
<>
- {isLoading ? (
-
- ) : (
- <>
-
{
- // Navigate to profile page with user identifier
- const identifier = userId || username;
- window.location.href = `/dashboard?tab=profile&user=${identifier}`;
- }}
- />
-
- {hasMore ? (
-
- ) : (
- leaderboardData.length > 0 && (
-
- You've reached the end of the leaderboard.
-
- )
- )}
-
- >
- )}
+
+
>
+ ) : (
+ No results found for this filter.
)}
-
- {/* Leaderboard Table - Projects */}
- {leaderboardType === "projects" && (
- <>
- {isLoadingProjects ? (
-
- ) : (
-
- )}
- >
- )}
-
- {/* CSS Animations */}
-
);
-}
+};
diff --git a/src/shared/components/ui/DatePicker.tsx b/src/shared/components/ui/DatePicker.tsx
index 30d56e4..e34c8b3 100644
--- a/src/shared/components/ui/DatePicker.tsx
+++ b/src/shared/components/ui/DatePicker.tsx
@@ -10,12 +10,12 @@ import { cn } from "../../../app/components/ui/utils";
interface DatePickerProps {
label?: string;
- value: string; // YYYY-MM-DD format
+ value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
className?: string;
- error?: string | null; // ADDED: Error message support
+ error?: string | null;
}
export function DatePicker({
@@ -25,149 +25,69 @@ export function DatePicker({
placeholder = "Pick a date",
required = false,
className = "",
- error // ADDED
+ error
}: DatePickerProps) {
const { theme } = useTheme();
const [open, setOpen] = React.useState(false);
+ const triggerRef = React.useRef(null);
- // ADDED: Check if there's an error
const isError = !!error;
- // Parse the date value (YYYY-MM-DD format)
- // Parse as UTC to avoid timezone issues
const date = React.useMemo(() => {
if (!value) return undefined;
try {
const [year, month, day] = value.split('-').map(Number);
- if (isNaN(year) || isNaN(month) || isNaN(day)) return undefined;
return new Date(Date.UTC(year, month - 1, day));
- } catch {
- return undefined;
- }
+ } catch { return undefined; }
}, [value]);
- // Handle date selection
const handleSelect = (selectedDate: Date | undefined) => {
if (selectedDate) {
- // Format as YYYY-MM-DD
- const formattedDate = format(selectedDate, "yyyy-MM-dd");
- onChange(formattedDate);
+ onChange(format(selectedDate, "yyyy-MM-dd"));
setOpen(false);
+ // Return focus to trigger after selection
+ triggerRef.current?.focus();
}
};
- // Format date for display
- const displayValue = date ? format(date, "MMM dd, yyyy") : "";
-
- // UPDATED: Input styling matching ModalInput with error support
- const inputClasses = `w-full px-4 py-3 rounded-[14px] backdrop-blur-[30px] border focus:outline-none transition-all text-[14px] flex items-center justify-between ${
- isError
- ? theme === 'dark'
- ? 'bg-red-500/10 border-red-500/40 text-[#f5f5f5] placeholder-red-300/50 focus:border-red-500/60'
- : 'bg-red-500/5 border-red-500/40 text-[#2d2820] placeholder-red-700/50 focus:border-red-500/60'
- : theme === 'dark'
- ? 'bg-white/[0.08] border-white/15 text-[#f5f5f5] placeholder-[#d4d4d4] focus:bg-white/[0.12] focus:border-[#c9983a]/30'
- : 'bg-white/[0.15] border-white/25 text-[#2d2820] placeholder-[#7a6b5a] focus:bg-white/[0.2] focus:border-[#c9983a]/30'
- } ${className}`;
-
- // Popover content styling for theme - using theme colors
- const popoverContentClasses = theme === 'dark'
- ? 'bg-[#1a1512] border-[#b8a898]/30 backdrop-blur-[30px] text-[#f5f5f5]'
- : 'bg-white/[0.4] border-[#c9983a]/20 backdrop-blur-[30px] text-[#2d2820]';
-
- // Calendar styling for theme - using theme colors consistently
- const calendarClassNames = {
- months: "flex flex-col sm:flex-row gap-2",
- month: "flex flex-col gap-4",
- caption: "flex justify-center pt-1 relative items-center w-full",
- caption_label: `text-sm font-medium ${theme === 'dark' ? 'text-[#f5f5f5]' : 'text-[#2d2820]'}`,
- nav: "flex items-center gap-1",
- nav_button: cn(
- "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 rounded-md border transition-colors",
- theme === 'dark'
- ? 'border-[#b8a898]/40 text-[#f5f5f5] hover:bg-[#b8a898]/20 hover:border-[#c9983a]/50'
- : 'border-[#c9983a]/20 text-[#2d2820] hover:bg-[#c9983a]/10 hover:border-[#c9983a]/30'
- ),
- nav_button_previous: "absolute left-1",
- nav_button_next: "absolute right-1",
- table: "w-full border-collapse space-x-1",
- head_row: "flex",
- head_cell: cn(
- "rounded-md w-8 font-normal text-[0.8rem]",
- theme === 'dark' ? 'text-[#d4d4d4]' : 'text-[#7a6b5a]'
- ),
- row: "flex w-full mt-2",
- cell: cn(
- "relative p-0 text-center text-sm focus-within:relative focus-within:z-20",
- "[&:has([aria-selected])]:rounded-md"
- ),
- day: cn(
- "h-8 w-8 p-0 font-normal rounded-md transition-colors",
- theme === 'dark'
- ? 'text-[#f5f5f5] hover:bg-[#b8a898]/15 hover:text-[#f5f5f5]'
- : 'text-[#2d2820] hover:bg-[#c9983a]/15 hover:text-[#2d2820]',
- "aria-selected:opacity-100"
- ),
- day_selected: cn(
- "bg-[#c9983a] text-white hover:bg-[#c9983a]/90 focus:bg-[#c9983a] focus:text-white",
- "hover:text-white focus:text-white"
- ),
- day_today: cn(
- theme === 'dark'
- ? 'bg-[#b8a898]/15 text-[#f5f5f5] border border-[#c9983a]/40'
- : 'bg-[#c9983a]/15 text-[#2d2820] border border-[#c9983a]/30'
- ),
- day_outside: cn(
- theme === 'dark' ? 'text-[#7a7a7a]' : 'text-[#b8a898]'
- ),
- day_disabled: cn(
- theme === 'dark' ? 'text-[#7a7a7a] opacity-50' : 'text-[#b8a898] opacity-50'
- ),
- day_hidden: "invisible",
- };
+ const inputClasses = cn(
+ "w-full px-4 py-3 rounded-[14px] backdrop-blur-[30px] border focus:outline-none transition-all text-[14px] flex items-center justify-between",
+ isError
+ ? (theme === 'dark' ? 'border-red-500/40' : 'border-red-500/40')
+ : (theme === 'dark' ? 'border-white/15' : 'border-white/25'),
+ className
+ );
return (
- {label && (
-
- )}
+ {label &&
}
-
+ { setOpen(false); triggerRef.current?.focus(); }}
+ >
-
- {/* ADDED: Error message display */}
- {isError && (
-
- {error}
-
- )}
+ {isError &&
{error}
}
);
-}
\ No newline at end of file
+}