+
- Transaction Date
+ Transaction Date
- {getFieldError('date') && (
-
- {getFieldError('date')}
+ {getFieldError("date") && (
+
+ {getFieldError("date")}
)}
- Amount
+ Amount
Enter transaction amount
- {getFieldError('amount') && (
-
- {getFieldError('amount')}
+ {getFieldError("amount") && (
+
+ {getFieldError("amount")}
)}
- Description
+ Description
Enter transaction description
- {getFieldError('description') && (
-
- {getFieldError('description')}
+ {getFieldError("description") && (
+
+ {getFieldError("description")}
)}
- {isPending ? : 'Submit'}
+ {isPending ? : "Submit"}
diff --git a/src/components/dashboard/transaction-form/test/TransactionForm.test.tsx b/src/components/dashboard/transaction-form/test/TransactionForm.test.tsx
new file mode 100644
index 0000000..cbabc30
--- /dev/null
+++ b/src/components/dashboard/transaction-form/test/TransactionForm.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import TransactionForm from "../TransactionForm";
+import { beforeAll, expect, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+
+beforeAll(() => {
+ window.HTMLElement.prototype.hasPointerCapture = vi.fn();
+ window.HTMLElement.prototype.scrollIntoView = function () {};
+});
+
+const mockMutate = vi.fn();
+
+vi.mock("@/hooks", () => {
+ return {
+ useTransactionMutation: () => ({
+ mutate: mockMutate,
+ isPending: false,
+ error: undefined,
+ }),
+ };
+});
+
+vi.mock("@/lib/trpc/client", () => ({
+ trpcClientRouter: {
+ transaction: {
+ create: {
+ useMutation: vi.fn().mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ error: undefined,
+ }),
+ },
+ update: {
+ useMutation: vi.fn().mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ error: undefined,
+ }),
+ },
+ },
+ },
+}));
+
+describe("TransactionForm Component Test Suites", () => {
+ // beforeEach(() => {
+ // mockMutate.mockClear();
+ // });
+
+ test("should render TransactionForm Component", () => {
+ render(
);
+ const formComponent = screen.getByTestId("transaction-form");
+ expect(formComponent).toBeInTheDocument();
+ });
+
+ test("should submit the form with correct data", async () => {
+ const user = userEvent.setup();
+ render(
);
+
+ // Get form elements
+ const radioBtn = screen.getByTestId("Transaction Type");
+ const expenseRadio = screen.getByLabelText(/expense/i);
+ const selectDropdown = screen.getByRole("combobox", {
+ name: /category/i,
+ });
+ const amountInput = screen.getByLabelText(/amount/i);
+ const descriptionInput = screen.getByLabelText(/description/i);
+ const datePicker = screen.getByTestId("date-picker-trigger");
+ const submitButton = screen.getByRole("button", { name: /submit/i });
+ // Simulate user interactions
+ await user.type(amountInput, "1500");
+ await user.type(descriptionInput, "Monthly Salary");
+ await user.click(expenseRadio);
+
+ expect(expenseRadio).toHaveValue("expense");
+
+ await user.click(selectDropdown);
+ const leisureOption = await screen.findByRole("option", {
+ name: /leisure/i,
+ });
+
+ // Assert the option is in the document before clicking
+ expect(leisureOption).toBeInTheDocument();
+
+ await user.click(leisureOption);
+
+ // Verify the selected value appears in the trigger
+ await waitFor(() => {
+ expect(selectDropdown).toHaveTextContent("Leisure");
+ });
+
+ await user.click(datePicker);
+
+ waitFor(async () => {
+ const dayToSelect = await screen.findByRole("button", { name: "15" });
+ await user.click(dayToSelect);
+ });
+ await user.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockMutate).toHaveBeenCalled();
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+
+ expect(radioBtn).toBeInTheDocument();
+ expect(selectDropdown).toBeInTheDocument();
+ expect(datePicker).toBeInTheDocument();
+ });
+});
diff --git a/src/components/dashboard/transactions-table/TransactionTable.tsx b/src/components/dashboard/transactions-table/TransactionTable.tsx
index 1160e0f..b0d3974 100644
--- a/src/components/dashboard/transactions-table/TransactionTable.tsx
+++ b/src/components/dashboard/transactions-table/TransactionTable.tsx
@@ -1,4 +1,4 @@
-'use client';
+"use client";
import {
ColumnDef,
@@ -9,7 +9,7 @@ import {
getSortedRowModel,
SortingState,
useReactTable,
-} from '@tanstack/react-table';
+} from "@tanstack/react-table";
import {
Table,
@@ -18,13 +18,13 @@ import {
TableHead,
TableHeader,
TableRow,
-} from '@/components/ui/table';
-import DataTablePagination from './TablePagination';
-import { ChangeEvent, useEffect, useState } from 'react';
-import { Badge } from '@/components/ui/badge';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { useDebounce } from '@/hooks';
+} from "@/components/ui/table";
+import DataTablePagination from "./TablePagination";
+import { ChangeEvent, useEffect, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { useDebounce } from "@/hooks";
+import { Card } from "@/components/ui/card";
interface DataTableProps
{
columns: ColumnDef[];
@@ -43,7 +43,7 @@ const TransactionTable = ({
pagination: { page, pageSize },
}: DataTableProps) => {
const [sorting, setSorting] = useState([]);
- const [searchValue, setSearchValue] = useState('');
+ const [searchValue, setSearchValue] = useState("");
const debouncedSearchValue = useDebounce(searchValue, 400);
const [pagination] = useState({
pageIndex: 0,
@@ -66,25 +66,28 @@ const TransactionTable = ({
// Update filter when debounced value changes
useEffect(() => {
- table.getColumn("description")?.setFilterValue(debouncedSearchValue || undefined);
+ table
+ .getColumn("description")
+ ?.setFilterValue(debouncedSearchValue || undefined);
}, [debouncedSearchValue, table]);
const handleOnChange = (event: ChangeEvent) => {
setSearchValue(event.target.value);
};
-
return (
-
-
-
+
+
+
-
+
{table.getHeaderGroups().map((headerGroup) => (
@@ -92,12 +95,12 @@ const TransactionTable = ({
return (
@@ -117,21 +120,22 @@ const TransactionTable = ({
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row, index) => (
{row.getVisibleCells().map((cell) => {
- if (cell.column.id === 'transactionType') {
+ if (cell.column.id === "transactionType") {
const value = String(cell.getValue());
return (
{value}
@@ -139,10 +143,10 @@ const TransactionTable = ({
);
}
- if (cell.column.id === 'id') {
+ if (cell.column.id === "id") {
return (
{index + 1}
@@ -152,10 +156,10 @@ const TransactionTable = ({
return (
@@ -170,7 +174,7 @@ const TransactionTable = ({
))
) : (
-
+
No results.
@@ -183,7 +187,7 @@ const TransactionTable = ({
count={count}
pagination={{ page, pageSize }}
/>
-
+
);
};
diff --git a/src/components/dashboard/transactions-table/TransactionTableSkeleton.tsx b/src/components/dashboard/transactions-table/TransactionTableSkeleton.tsx
index 4718abe..ca2b5ba 100644
--- a/src/components/dashboard/transactions-table/TransactionTableSkeleton.tsx
+++ b/src/components/dashboard/transactions-table/TransactionTableSkeleton.tsx
@@ -1,4 +1,5 @@
-import { Skeleton } from '@/components/ui/skeleton';
+import { Card } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
@@ -6,7 +7,7 @@ import {
TableHead,
TableHeader,
TableRow,
-} from '@/components/ui/table';
+} from "@/components/ui/table";
interface TransactionTableSkeletonProps {
rows?: number;
@@ -18,13 +19,16 @@ const TransactionTableSkeleton = ({
columns = 5,
}: TransactionTableSkeletonProps) => {
return (
-
+
+
+
+
{Array.from({ length: columns }).map((_, index) => (
-
+
))}
@@ -34,7 +38,7 @@ const TransactionTableSkeleton = ({
{Array.from({ length: columns }).map((_, colIndex) => (
-
+
))}
@@ -42,14 +46,14 @@ const TransactionTableSkeleton = ({
-
+
+
);
};
diff --git a/src/components/dashboard/transactions-table/colums.tsx b/src/components/dashboard/transactions-table/columns.tsx
similarity index 54%
rename from src/components/dashboard/transactions-table/colums.tsx
rename to src/components/dashboard/transactions-table/columns.tsx
index 1dae3aa..62a2315 100644
--- a/src/components/dashboard/transactions-table/colums.tsx
+++ b/src/components/dashboard/transactions-table/columns.tsx
@@ -1,14 +1,14 @@
-'use client';
+"use client";
-import { ColumnDef, Row } from '@tanstack/react-table';
-import { Button } from '@/components/ui/button';
+import { ColumnDef, Row } from "@tanstack/react-table";
+import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
+} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogClose,
@@ -18,45 +18,37 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
-} from '@/components/ui/dialog';
-import { MoreHorizontal, ArrowUpDown } from 'lucide-react';
-import { MdOutlineDeleteSweep } from 'react-icons/md';
-import { format } from 'date-fns';
-import { trpcClientRouter } from '@/lib/trpc/client';
-import { useEffect, useState } from 'react';
-import { toast } from 'sonner';
-import { useRouter } from 'next/navigation';
-import Link from 'next/link';
-import { ROUTES } from '@/types';
-
-export type Transaction = {
- id: string;
- description: string;
- amount: string;
- transactionType: 'income' | 'expense';
- category: string;
-};
+} from "@/components/ui/dialog";
+import { MoreHorizontal, ArrowUpDown } from "lucide-react";
+import { MdOutlineDeleteSweep } from "react-icons/md";
+import { format } from "date-fns";
+import { trpcClientRouter } from "@/lib/trpc/client";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { ROUTES, Transaction } from "@/types";
export const TransactionColumns: ColumnDef
[] = [
{
- accessorKey: 'id',
- header: '#',
+ accessorKey: "id",
+ header: "#",
},
{
- accessorKey: 'description',
- header: 'Description',
+ accessorKey: "description",
+ header: "Description",
},
{
- accessorKey: 'transactionDate',
+ accessorKey: "transactionDate",
header: ({ column }) => {
return (
column.toggleSorting(column.getIsSorted() === 'asc')}
+ title="Sort by Date"
+ variant="ghost"
+ onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Transaction Date
-
+
);
},
@@ -64,39 +56,39 @@ export const TransactionColumns: ColumnDef[] = [
cell: ({ row }) => {
return (
- {format(new Date(row.getValue('transactionDate')), 'dd/MM/yyyy')}
+ {format(new Date(row.getValue("transactionDate")), "dd/MM/yyyy")}
);
},
},
{
- accessorKey: 'amount',
+ accessorKey: "amount",
header: ({ column }) => {
return (
column.toggleSorting(column.getIsSorted() === 'asc')}
+ title="Sort by Amount"
+ variant="ghost"
+ onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Amount
-
+
);
},
cell: ({ row }) => {
- const amount = parseFloat(row.getValue('amount'));
- const formatted = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'GBP',
+ const amount = parseFloat(row.getValue("amount"));
+ const formatted = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "GBP",
}).format(amount);
return {formatted}
;
},
},
- { accessorKey: 'transactionType', header: 'Type' },
- { accessorKey: 'category', header: 'Category' },
+ { accessorKey: "transactionType", header: "Type" },
+ { accessorKey: "category", header: "Category" },
{
- id: 'actions',
- header: 'Actions',
+ id: "actions",
+ header: "Actions",
cell: ({ row }) => ,
},
];
@@ -110,15 +102,15 @@ const ActionsCell = ({ row }: { row: Row }) => {
const handleDelete = () => {
try {
- mutation.mutate({ id: row.getValue('id') });
+ mutation.mutate({ id: row.getValue("id") });
} catch (error) {
- console.log('Error deleting transaction:', error);
+ console.log("Error deleting transaction:", error);
}
};
useEffect(() => {
if (mutation.isSuccess) {
- toast.success('Transaction deleted successfully');
+ toast.success("Transaction deleted successfully");
router.refresh();
setIsDialogOpen(false);
setIsDropdownOpen(false);
@@ -128,18 +120,22 @@ const ActionsCell = ({ row }: { row: Row }) => {
<>
-
- Open menu
-
+
+ Open menu
+
-
-
+
+
- Details{' '}
+ Details{" "}
@@ -149,15 +145,16 @@ const ActionsCell = ({ row }: { row: Row }) => {
onSelect={(e) => {
e.preventDefault();
}}
- title='Delete'
- variant='destructive'
- className='cursor-pointer'
+ title="Delete"
+ variant="destructive"
+ className="cursor-pointer"
+ data-testid={`transaction-actions-delete-${row.getValue("id")}`}
>
-
+
Delete
-
+
Delete Transaction #{row.index + 1}
@@ -167,12 +164,12 @@ const ActionsCell = ({ row }: { row: Row }) => {
- Cancel
+ Cancel
Yes
diff --git a/src/components/dashboard/transactions-table/test/TransactionTable.test.tsx b/src/components/dashboard/transactions-table/test/TransactionTable.test.tsx
new file mode 100644
index 0000000..cbe3fe8
--- /dev/null
+++ b/src/components/dashboard/transactions-table/test/TransactionTable.test.tsx
@@ -0,0 +1,163 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import TransactionTable from "../TransactionTable";
+import { Transaction } from "@/types";
+import userEvent from "@testing-library/user-event";
+import { TransactionColumns } from "../columns";
+vi.mock("@/hooks", () => ({
+ useDebounce: (value: string) => value,
+}));
+vi.mock("date-fns", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ format: vi.fn(),
+ };
+});
+const mockMutate = vi.fn();
+const mockMutateAsync = vi.fn();
+vi.mock("@/lib/trpc/client", () => ({
+ trpcClientRouter: {
+ transaction: {
+ delete: {
+ useMutation: vi.fn(() => ({
+ mutate: mockMutate,
+ mutateAsync: mockMutateAsync,
+ isPending: false,
+ isError: false,
+ isSuccess: false,
+ data: undefined,
+ error: null,
+ })),
+ },
+ },
+ },
+}));
+
+vi.mock("../columns", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ TransactionColumns: actual.TransactionColumns,
+ };
+});
+vi.mock("next/navigation", async (importOriginal) => {
+ const actual = await importOriginal();
+ const { useRouter } =
+ await vi.importActual(
+ "next-router-mock"
+ );
+ const usePathname = vi.fn().mockImplementation(() => {
+ const router = useRouter();
+ return router.pathname;
+ });
+ const useSearchParams = vi.fn().mockImplementation(() => {
+ const router = useRouter();
+ return new URLSearchParams(router.query?.toString());
+ });
+ return {
+ ...actual,
+ useRouter: vi.fn().mockImplementation(useRouter),
+ usePathname,
+ useSearchParams,
+ };
+});
+
+const mockData: Transaction[] = [
+ {
+ id: "1",
+ description: "Salary",
+ amount: "3000",
+ category: "Income",
+ transactionType: "income",
+ },
+ {
+ id: "2",
+ description: "Rent Payment",
+ amount: "1200",
+ transactionType: "expense",
+ category: "Housing",
+ },
+];
+
+describe("TransactionTable Component Test Suites", () => {
+ it("should render Transaction Table", async () => {
+ render(
+
+ );
+ expect(screen.getByTestId("transactions-table")).toBeInTheDocument();
+ });
+ it("should show no results when there is no data", () => {
+ render(
+
+ );
+ expect(screen.getByText("No results.")).toBeInTheDocument();
+ });
+ it("should render correct amount for the transactions", async () => {
+ render(
+
+ );
+ const rows = await screen.findAllByRole("row");
+ expect(rows).toHaveLength(3);
+ });
+ it("should render correct query from the search field", async () => {
+ render(
+
+ );
+ const user = userEvent.setup();
+ const searchInput = screen.getByLabelText(/search transactions/i);
+ const testQuery = "Rent";
+ await user.type(searchInput, testQuery);
+ expect(searchInput).toHaveValue(testQuery);
+ const row = await screen.findByRole("row", { name: /rent/i });
+ expect(row).toBeInTheDocument();
+ expect(screen.queryByText(/salary/i)).not.toBeInTheDocument();
+ });
+ it("should delete a transaction when delete action is triggered", async () => {
+ render(
+
+ );
+ const user = userEvent.setup();
+ const SalaryRow = await screen.findByRole("row", { name: /salary/i });
+ expect(SalaryRow).toBeInTheDocument();
+ const deleteAction = await screen.findByTestId(
+ `transaction-actions-${mockData[0].id}`
+ );
+ expect(deleteAction).toBeInTheDocument();
+ await user.click(deleteAction);
+ const deleteButton = await screen.findByTestId(
+ `transaction-actions-delete-${mockData[0].id}`
+ );
+ expect(deleteButton).toBeInTheDocument();
+ await user.click(deleteButton);
+ const confirmButton = await screen.findByRole("button", { name: "Yes" });
+ expect(confirmButton).toBeInTheDocument();
+ await user.click(confirmButton);
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/features/Carousel.tsx b/src/components/features/Carousel.tsx
new file mode 100644
index 0000000..e2e0025
--- /dev/null
+++ b/src/components/features/Carousel.tsx
@@ -0,0 +1,329 @@
+//@ts-nocheck
+"use client";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { motion, useMotionValue, useTransform } from "motion/react";
+import { FiFileText, FiLayers } from "react-icons/fi";
+import Image from "next/image";
+import { MdOutlineDashboardCustomize } from "react-icons/md";
+
+const DEFAULT_ITEMS = [
+ {
+ title: "Add your transaction",
+ description: "Easily add income and expenses.",
+ id: 1,
+ icon: ,
+ imageSrc: "/transaction-form.png",
+ },
+ {
+ title: "Streamline Your Transaction Flows",
+ description:
+ "With our intuitive interface, you can easily see all your transactions at a glance.",
+ id: 2,
+ imageSrc: "/test.png",
+ icon: ,
+ },
+ {
+ title: "Display All Transactions in One Place",
+ description: "View all your financial transactions in a single, table",
+ id: 3,
+ icon: ,
+ imageSrc: "/transactions-table.png",
+ },
+];
+
+const DRAG_BUFFER = 0;
+const VELOCITY_THRESHOLD = 500;
+const GAP = 16;
+const SPRING_OPTIONS = { type: "spring", stiffness: 300, damping: 30 };
+
+function CarouselItem({
+ item,
+ index,
+ itemWidth,
+ round,
+ trackItemOffset,
+ x,
+ transition,
+}) {
+ const range = [
+ -(index + 1) * trackItemOffset,
+ -index * trackItemOffset,
+ -(index - 1) * trackItemOffset,
+ ];
+ const outputRange = [90, 0, -90];
+ const rotateY = useTransform(x, range, outputRange, { clamp: false });
+
+ return (
+
+
+
+ {item.icon}
+
+
+
+
+
+
+
{item.title}
+
{item.description}
+
+
+ );
+}
+
+export default function Carousel({
+ items = DEFAULT_ITEMS,
+ baseWidth = 300,
+ autoplay = false,
+ autoplayDelay = 3000,
+ pauseOnHover = false,
+ loop = false,
+ round = false,
+}) {
+ const containerPadding = 16;
+ const [containerWidth, setContainerWidth] = useState(baseWidth);
+
+ // Memoize itemWidth and trackItemOffset
+ const itemWidth = useMemo(
+ () => containerWidth - containerPadding * 2,
+ [containerWidth]
+ );
+
+ const trackItemOffset = useMemo(() => itemWidth + GAP, [itemWidth]);
+ const itemsForRender = useMemo(() => {
+ if (!loop) return items;
+ if (items.length === 0) return [];
+ return [items[items.length - 1], ...items, items[0]];
+ }, [items, loop]);
+
+ const [position, setPosition] = useState(loop ? 1 : 0);
+ const x = useMotionValue(0);
+ const [isHovered, setIsHovered] = useState(false);
+ const [isJumping, setIsJumping] = useState(false);
+ const [isAnimating, setIsAnimating] = useState(false);
+
+ const containerRef = useRef(null);
+ // Add resize observer to track container width
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const updateWidth = () => {
+ if (containerRef.current) {
+ const width = containerRef.current.offsetWidth;
+ setContainerWidth(width);
+ }
+ };
+
+ // Initial measurement
+ updateWidth();
+
+ // Update on resize
+ const resizeObserver = new ResizeObserver(updateWidth);
+ resizeObserver.observe(containerRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, []);
+ useEffect(() => {
+ if (pauseOnHover && containerRef.current) {
+ const container = containerRef.current;
+ const handleMouseEnter = () => setIsHovered(true);
+ const handleMouseLeave = () => setIsHovered(false);
+ container.addEventListener("mouseenter", handleMouseEnter);
+ container.addEventListener("mouseleave", handleMouseLeave);
+ return () => {
+ container.removeEventListener("mouseenter", handleMouseEnter);
+ container.removeEventListener("mouseleave", handleMouseLeave);
+ };
+ }
+ }, [pauseOnHover]);
+
+ useEffect(() => {
+ if (!autoplay || itemsForRender.length <= 1) return undefined;
+ if (pauseOnHover && isHovered) return undefined;
+
+ const timer = setInterval(() => {
+ setPosition((prev) => Math.min(prev + 1, itemsForRender.length - 1));
+ }, autoplayDelay);
+
+ return () => clearInterval(timer);
+ }, [autoplay, autoplayDelay, isHovered, pauseOnHover, itemsForRender.length]);
+
+ useEffect(() => {
+ const startingPosition = loop ? 1 : 0;
+ setPosition(startingPosition);
+ x.set(-startingPosition * trackItemOffset);
+ }, [items.length, loop, trackItemOffset, x]);
+
+ useEffect(() => {
+ if (!loop && position > itemsForRender.length - 1) {
+ setPosition(Math.max(0, itemsForRender.length - 1));
+ }
+ }, [itemsForRender.length, loop, position]);
+
+ const effectiveTransition = isJumping ? { duration: 0 } : SPRING_OPTIONS;
+
+ const handleAnimationStart = () => {
+ setIsAnimating(true);
+ };
+
+ const handleAnimationComplete = () => {
+ if (!loop || itemsForRender.length <= 1) {
+ setIsAnimating(false);
+ return;
+ }
+ const lastCloneIndex = itemsForRender.length - 1;
+
+ if (position === lastCloneIndex) {
+ setIsJumping(true);
+ const target = 1;
+ setPosition(target);
+ x.set(-target * trackItemOffset);
+ requestAnimationFrame(() => {
+ setIsJumping(false);
+ setIsAnimating(false);
+ });
+ return;
+ }
+
+ if (position === 0) {
+ setIsJumping(true);
+ const target = items.length;
+ setPosition(target);
+ x.set(-target * trackItemOffset);
+ requestAnimationFrame(() => {
+ setIsJumping(false);
+ setIsAnimating(false);
+ });
+ return;
+ }
+
+ setIsAnimating(false);
+ };
+
+ const handleDragEnd = (_, info) => {
+ const { offset, velocity } = info;
+ const direction =
+ offset.x < -DRAG_BUFFER || velocity.x < -VELOCITY_THRESHOLD
+ ? 1
+ : offset.x > DRAG_BUFFER || velocity.x > VELOCITY_THRESHOLD
+ ? -1
+ : 0;
+
+ if (direction === 0) return;
+
+ setPosition((prev) => {
+ const next = prev + direction;
+ const max = itemsForRender.length - 1;
+ return Math.max(0, Math.min(next, max));
+ });
+ };
+
+ const dragProps = loop
+ ? {}
+ : {
+ dragConstraints: {
+ left: -trackItemOffset * Math.max(itemsForRender.length - 1, 0),
+ right: 0,
+ },
+ };
+
+ const activeIndex =
+ items.length === 0
+ ? 0
+ : loop
+ ? (position - 1 + items.length) % items.length
+ : Math.min(position, items.length - 1);
+
+ return (
+
+
+ {itemsForRender.map((item, index) => (
+
+ ))}
+
+
+
+ {items.map((_, index) => (
+ setPosition(loop ? index + 1 : index)}
+ key={index}
+ >
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/features/DatePicker.tsx b/src/components/features/DatePicker.tsx
index 83e634d..5aab09c 100644
--- a/src/components/features/DatePicker.tsx
+++ b/src/components/features/DatePicker.tsx
@@ -1,16 +1,16 @@
-'use client';
+"use client";
-import * as React from 'react';
-import { format } from 'date-fns';
-import { Calendar as CalendarIcon } from 'lucide-react';
+import * as React from "react";
+import { format } from "date-fns";
+import { Calendar as CalendarIcon } from "lucide-react";
-import { Button } from '@/components/ui/button';
-import { Calendar } from '@/components/ui/calendar';
+import { Button } from "@/components/ui/button";
+import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
-} from '@/components/ui/popover';
+} from "@/components/ui/popover";
type DatePickerProps = {
defaultValue?: Date;
@@ -19,7 +19,7 @@ type DatePickerProps = {
};
const DatePicker: React.FC = ({
defaultValue,
- label = 'Pick a date',
+ label = "Pick a date",
onDateChange,
}) => {
const [date, setDate] = React.useState(defaultValue);
@@ -35,28 +35,31 @@ const DatePicker: React.FC = ({
- {date && !onDateChange ? format(date, 'PPP') : {label} }
+ {date && !onDateChange ? format(date, "PPP") : {label} }
-
+
{' '}
+ type="hidden"
+ name="date"
+ aria-label="date"
+ value={date ? format(date, "yyyy-MM-dd") : ""}
+ />{" "}
);
};
diff --git a/src/components/features/DotGrid.tsx b/src/components/features/DotGrid.tsx
new file mode 100644
index 0000000..233b77e
--- /dev/null
+++ b/src/components/features/DotGrid.tsx
@@ -0,0 +1,282 @@
+//@ts-nocheck
+"use client";
+import { useRef, useEffect, useCallback, useMemo } from "react";
+import { gsap } from "gsap";
+import { InertiaPlugin } from "gsap/InertiaPlugin";
+gsap.registerPlugin(InertiaPlugin);
+
+const throttle = (func, limit) => {
+ let lastCall = 0;
+ return function (...args) {
+ const now = performance.now();
+ if (now - lastCall >= limit) {
+ lastCall = now;
+ func.apply(this, args);
+ }
+ };
+};
+
+function hexToRgb(hex) {
+ const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
+ if (!m) return { r: 0, g: 0, b: 0 };
+ return {
+ r: parseInt(m[1], 16),
+ g: parseInt(m[2], 16),
+ b: parseInt(m[3], 16),
+ };
+}
+
+const DotGrid = ({
+ dotSize = 16,
+ gap = 32,
+ baseColor = "#5227FF",
+ activeColor = "#5227FF",
+ proximity = 150,
+ speedTrigger = 100,
+ shockRadius = 250,
+ shockStrength = 5,
+ maxSpeed = 5000,
+ resistance = 750,
+ returnDuration = 1.5,
+ className = "",
+ style,
+}) => {
+ const wrapperRef = useRef(null);
+ const canvasRef = useRef(null);
+ const dotsRef = useRef([]);
+ const pointerRef = useRef({
+ x: 0,
+ y: 0,
+ vx: 0,
+ vy: 0,
+ speed: 0,
+ lastTime: 0,
+ lastX: 0,
+ lastY: 0,
+ });
+
+ const baseRgb = useMemo(() => hexToRgb(baseColor), [baseColor]);
+ const activeRgb = useMemo(() => hexToRgb(activeColor), [activeColor]);
+
+ const circlePath = useMemo(() => {
+ if (typeof window === "undefined" || !window.Path2D) return null;
+
+ const p = new Path2D();
+ p.arc(0, 0, dotSize / 2, 0, Math.PI * 2);
+ return p;
+ }, [dotSize]);
+
+ const buildGrid = useCallback(() => {
+ const wrap = wrapperRef.current;
+ const canvas = canvasRef.current;
+ if (!wrap || !canvas) return;
+
+ const { width, height } = wrap.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ const ctx = canvas.getContext("2d");
+ if (ctx) ctx.scale(dpr, dpr);
+
+ const cols = Math.floor((width + gap) / (dotSize + gap));
+ const rows = Math.floor((height + gap) / (dotSize + gap));
+ const cell = dotSize + gap;
+
+ const gridW = cell * cols - gap;
+ const gridH = cell * rows - gap;
+
+ const extraX = width - gridW;
+ const extraY = height - gridH;
+
+ const startX = extraX / 2 + dotSize / 2;
+ const startY = extraY / 2 + dotSize / 2;
+
+ const dots = [];
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ const cx = startX + x * cell;
+ const cy = startY + y * cell;
+ dots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false });
+ }
+ }
+ dotsRef.current = dots;
+ }, [dotSize, gap]);
+
+ useEffect(() => {
+ if (!circlePath) return;
+
+ let rafId;
+ const proxSq = proximity * proximity;
+
+ const draw = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ const { x: px, y: py } = pointerRef.current;
+
+ for (const dot of dotsRef.current) {
+ const ox = dot.cx + dot.xOffset;
+ const oy = dot.cy + dot.yOffset;
+ const dx = dot.cx - px;
+ const dy = dot.cy - py;
+ const dsq = dx * dx + dy * dy;
+
+ let style = baseColor;
+ if (dsq <= proxSq) {
+ const dist = Math.sqrt(dsq);
+ const t = 1 - dist / proximity;
+ const r = Math.round(baseRgb.r + (activeRgb.r - baseRgb.r) * t);
+ const g = Math.round(baseRgb.g + (activeRgb.g - baseRgb.g) * t);
+ const b = Math.round(baseRgb.b + (activeRgb.b - baseRgb.b) * t);
+ style = `rgb(${r},${g},${b})`;
+ }
+
+ ctx.save();
+ ctx.translate(ox, oy);
+ ctx.fillStyle = style;
+ ctx.fill(circlePath);
+ ctx.restore();
+ }
+
+ rafId = requestAnimationFrame(draw);
+ };
+
+ draw();
+ return () => cancelAnimationFrame(rafId);
+ }, [proximity, baseColor, activeRgb, baseRgb, circlePath]);
+
+ useEffect(() => {
+ buildGrid();
+ let ro = null;
+ if ("ResizeObserver" in window) {
+ ro = new ResizeObserver(buildGrid);
+ if (wrapperRef.current) {
+ ro.observe(wrapperRef.current);
+ }
+ } else {
+ window.addEventListener("resize", buildGrid);
+ }
+ return () => {
+ if (ro) ro.disconnect();
+ else window.removeEventListener("resize", buildGrid);
+ };
+ }, [buildGrid]);
+
+ useEffect(() => {
+ const onMove = (e) => {
+ const now = performance.now();
+ const pr = pointerRef.current;
+ const dt = pr.lastTime ? now - pr.lastTime : 16;
+ const dx = e.clientX - pr.lastX;
+ const dy = e.clientY - pr.lastY;
+ let vx = (dx / dt) * 1000;
+ let vy = (dy / dt) * 1000;
+ let speed = Math.hypot(vx, vy);
+ if (speed > maxSpeed) {
+ const scale = maxSpeed / speed;
+ vx *= scale;
+ vy *= scale;
+ speed = maxSpeed;
+ }
+ pr.lastTime = now;
+ pr.lastX = e.clientX;
+ pr.lastY = e.clientY;
+ pr.vx = vx;
+ pr.vy = vy;
+ pr.speed = speed;
+
+ const rect = canvasRef.current.getBoundingClientRect();
+ pr.x = e.clientX - rect.left;
+ pr.y = e.clientY - rect.top;
+
+ for (const dot of dotsRef.current) {
+ const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y);
+ if (speed > speedTrigger && dist < proximity && !dot._inertiaApplied) {
+ dot._inertiaApplied = true;
+ gsap.killTweensOf(dot);
+ const pushX = dot.cx - pr.x + vx * 0.005;
+ const pushY = dot.cy - pr.y + vy * 0.005;
+ gsap.to(dot, {
+ inertia: { xOffset: pushX, yOffset: pushY, resistance },
+ onComplete: () => {
+ gsap.to(dot, {
+ xOffset: 0,
+ yOffset: 0,
+ duration: returnDuration,
+ ease: "elastic.out(1,0.75)",
+ });
+ dot._inertiaApplied = false;
+ },
+ });
+ }
+ }
+ };
+
+ const onClick = (e) => {
+ const rect = canvasRef.current.getBoundingClientRect();
+ const cx = e.clientX - rect.left;
+ const cy = e.clientY - rect.top;
+ for (const dot of dotsRef.current) {
+ const dist = Math.hypot(dot.cx - cx, dot.cy - cy);
+ if (dist < shockRadius && !dot._inertiaApplied) {
+ dot._inertiaApplied = true;
+ gsap.killTweensOf(dot);
+ const falloff = Math.max(0, 1 - dist / shockRadius);
+ const pushX = (dot.cx - cx) * shockStrength * falloff;
+ const pushY = (dot.cy - cy) * shockStrength * falloff;
+ gsap.to(dot, {
+ inertia: { xOffset: pushX, yOffset: pushY, resistance },
+ onComplete: () => {
+ gsap.to(dot, {
+ xOffset: 0,
+ yOffset: 0,
+ duration: returnDuration,
+ ease: "elastic.out(1,0.75)",
+ });
+ dot._inertiaApplied = false;
+ },
+ });
+ }
+ }
+ };
+
+ const throttledMove = throttle(onMove, 50);
+ window.addEventListener("mousemove", throttledMove, { passive: true });
+ window.addEventListener("click", onClick);
+
+ return () => {
+ window.removeEventListener("mousemove", throttledMove);
+ window.removeEventListener("click", onClick);
+ };
+ }, [
+ maxSpeed,
+ speedTrigger,
+ proximity,
+ resistance,
+ returnDuration,
+ shockRadius,
+ shockStrength,
+ ]);
+
+ return (
+
+ );
+};
+
+export default DotGrid;
diff --git a/src/components/layout/Navbar/Navbar.tsx b/src/components/layout/Navbar/Navbar.tsx
index 49e24a8..8b1b348 100644
--- a/src/components/layout/Navbar/Navbar.tsx
+++ b/src/components/layout/Navbar/Navbar.tsx
@@ -1,67 +1,109 @@
-'use client';
-import { signOutUser } from '@/app/actions/auth/auth.actions';
-import { Button } from '@/components/ui/button';
-import Container from '@/components/ui/container';
-import { ThemeSwitcher } from '@/components/ui/theme-switcher';
-import { SelectUser } from '@/db/schema';
-import { ROUTES } from '@/types';
-import { useTheme } from 'next-themes';
-import Image from 'next/image';
-import Link from 'next/link';
-import { usePathname, useRouter } from 'next/navigation';
-import { useActionState, useCallback, useEffect } from 'react';
-import { toast } from 'sonner';
+"use client";
+import { signOutUser } from "@/app/actions/auth/auth.actions";
+import { Button } from "@/components/ui/button";
+import Container from "@/components/ui/container";
+import { ThemeSwitcher } from "@/components/ui/theme-switcher";
+import { SelectUser } from "@/db/schema";
+import { ROUTES } from "@/types";
+import { useTheme } from "next-themes";
+import Image from "next/image";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { useActionState, useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Menu } from "lucide-react";
+import { RiCollapseHorizontalLine } from "react-icons/ri";
type Props = {
- user: Omit | undefined;
+ user: Omit | undefined;
activePath?: string;
};
export const Navbar: React.FC = ({ user }) => {
const { setTheme, theme } = useTheme();
+ const [isScrolled, setIsScrolled] = useState(false);
+ const [isExpanded, setIsExpanded] = useState(false);
const pathname = usePathname();
const [state, formAction, isPending] = useActionState(signOutUser, {
- error: '',
+ error: "",
success: false,
});
const router = useRouter();
+
+ useEffect(() => {
+ const handleScroll = () => {
+ if (window.scrollY > 100) {
+ setIsScrolled(true);
+ } else {
+ setIsScrolled(false);
+ setIsExpanded(false);
+ }
+ };
+
+ window.addEventListener("scroll", handleScroll);
+ return () => window.removeEventListener("scroll", handleScroll);
+ }, []);
+
const handleSignOut = useCallback(() => {
if (state?.success) {
router.replace(ROUTES.SIGN_IN, {});
- toast.success('User signed out successfully');
+ toast.success("User signed out successfully");
}
}, [state?.success, router]);
+
useEffect(() => {
handleSignOut();
}, [handleSignOut]);
+
+ if (isScrolled && !isExpanded) {
+ return (
+ setIsExpanded(true)}
+ className="fixed top-4 left-4 z-50 rounded-full h-12 w-12 p-0 animate-in fade-in slide-in-from-left-5 duration-300"
+ variant="default"
+ >
+
+
+ );
+ }
+
return (
-
+
-
-
+
+
-
-
+
+
{!user ? (
<>
Log in
Sign-up
@@ -69,15 +111,19 @@ export const Navbar: React.FC = ({ user }) => {
>
) : (
<>
-
+
Dashboard
-
setTheme(theme)}
- value={theme as 'light' | 'dark' | 'system'}
- />
+
+ setTheme(theme)}
+ value={theme as "light" | "dark" | "system"}
+ />
+
+ {isScrolled && (
+ setIsExpanded(false)}
+ variant="ghost"
+ size="icon"
+ title="Close Navbar"
+ aria-label="Close Navbar"
+ className="h-[32px] w-8 flex rounded-full bg-background ring-full ring-border relative left-6 animate-in fade-in zoom-in-50 duration-200"
+ >
+
+
+ )}
diff --git a/src/components/ui/container.tsx b/src/components/ui/container.tsx
index d9db0c2..38bc590 100644
--- a/src/components/ui/container.tsx
+++ b/src/components/ui/container.tsx
@@ -4,7 +4,9 @@ type Props = {
};
const Container: React.FC
= ({ children, className }) => {
return (
- {children}
+
+ {children}
+
);
};
export default Container;
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 25e5439..87cf50c 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -1,27 +1,27 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as SelectPrimitive from "@radix-ui/react-select"
-import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function SelectGroup({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function SelectValue({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function SelectTrigger({
@@ -30,7 +30,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps & {
- size?: "sm" | "default"
+ size?: "sm" | "default";
}) {
return (
- )
+ );
}
function SelectContent({
@@ -84,7 +84,7 @@ function SelectContent({
- )
+ );
}
function SelectLabel({
@@ -97,7 +97,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
- )
+ );
}
function SelectItem({
@@ -121,7 +121,7 @@ function SelectItem({
{children}
- )
+ );
}
function SelectSeparator({
@@ -134,7 +134,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
- )
+ );
}
function SelectScrollUpButton({
@@ -152,7 +152,7 @@ function SelectScrollUpButton({
>
- )
+ );
}
function SelectScrollDownButton({
@@ -170,7 +170,7 @@ function SelectScrollDownButton({
>
- )
+ );
}
export {
@@ -184,4 +184,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
-}
+};
diff --git a/src/db/migrations/0004_flashy_thena.sql b/src/db/migrations/0004_flashy_thena.sql
new file mode 100644
index 0000000..b2af63c
--- /dev/null
+++ b/src/db/migrations/0004_flashy_thena.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "account" ADD COLUMN "user_email" text NOT NULL;--> statement-breakpoint
+ALTER TABLE "account" ADD CONSTRAINT "account_user_email_user_email_fk" FOREIGN KEY ("user_email") REFERENCES "public"."user"("email") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/src/db/migrations/0005_mushy_madame_masque.sql b/src/db/migrations/0005_mushy_madame_masque.sql
new file mode 100644
index 0000000..6063d7b
--- /dev/null
+++ b/src/db/migrations/0005_mushy_madame_masque.sql
@@ -0,0 +1 @@
+ALTER TABLE "account" ALTER COLUMN "user_email" DROP NOT NULL;
\ No newline at end of file
diff --git a/src/db/migrations/meta/0004_snapshot.json b/src/db/migrations/meta/0004_snapshot.json
new file mode 100644
index 0000000..ef8f0b1
--- /dev/null
+++ b/src/db/migrations/meta/0004_snapshot.json
@@ -0,0 +1,429 @@
+{
+ "id": "424eed41-7132-4e11-a612-5486a1e7afe3",
+ "prevId": "f82bd6b7-2978-43ff-bcda-fb1e54135843",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_email": {
+ "name": "user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "account_user_email_user_email_fk": {
+ "name": "account_user_email_user_email_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_email"
+ ],
+ "columnsTo": [
+ "email"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.transaction": {
+ "name": "transaction",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "transaction_date": {
+ "name": "transaction_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "amount": {
+ "name": "amount",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transaction_type": {
+ "name": "transaction_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "transaction_user_id_user_id_fk": {
+ "name": "transaction_user_id_user_id_fk",
+ "tableFrom": "transaction",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/0005_snapshot.json b/src/db/migrations/meta/0005_snapshot.json
new file mode 100644
index 0000000..59b46dc
--- /dev/null
+++ b/src/db/migrations/meta/0005_snapshot.json
@@ -0,0 +1,429 @@
+{
+ "id": "d7263c63-7b37-4da6-a95b-eb3e8b23551e",
+ "prevId": "424eed41-7132-4e11-a612-5486a1e7afe3",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_email": {
+ "name": "user_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "account_user_email_user_email_fk": {
+ "name": "account_user_email_user_email_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_email"
+ ],
+ "columnsTo": [
+ "email"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.transaction": {
+ "name": "transaction",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "transaction_date": {
+ "name": "transaction_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "amount": {
+ "name": "amount",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transaction_type": {
+ "name": "transaction_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "transaction_user_id_user_id_fk": {
+ "name": "transaction_user_id_user_id_fk",
+ "tableFrom": "transaction",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json
index 1eeb3ae..b619980 100644
--- a/src/db/migrations/meta/_journal.json
+++ b/src/db/migrations/meta/_journal.json
@@ -29,6 +29,20 @@
"when": 1762616669310,
"tag": "0003_simple_vertigo",
"breakpoints": true
+ },
+ {
+ "idx": 4,
+ "version": "7",
+ "when": 1766689975791,
+ "tag": "0004_flashy_thena",
+ "breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "7",
+ "when": 1766692645530,
+ "tag": "0005_mushy_madame_masque",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/db/schema.ts b/src/db/schema.ts
index e524d14..6302d49 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -37,6 +37,7 @@ export const account = pgTable('account', {
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
+ userEmail:text("user_email"),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
diff --git a/src/db/seed.ts b/src/db/seed.ts
index a50dd8e..88c4764 100644
--- a/src/db/seed.ts
+++ b/src/db/seed.ts
@@ -20,8 +20,8 @@ export async function seedTransactions(userId: string, count: number) {
// Usage example:
const main = async () => {
- // Seed 10 transactions for a user with ID 'user-123'
- await seedTransactions('bfxBiPxvY9E5RQVPcG3iH3DIpsjU0aRo', 10);
+ // Seed 30 transactions for a user with ID 'user-123'
+ await seedTransactions(process.env.TEST_USER_ID!, 30);
};
main().catch((e) => {
diff --git a/src/types/index.ts b/src/types/index.ts
index b69c07a..34f9563 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -8,3 +8,12 @@ export enum ROUTES {
TRANSACTIONS = '/dashboard/transactions',
NEW_TRANSACTION = '/dashboard/transactions/new',
}
+
+
+export type Transaction = {
+ id: string;
+ description: string;
+ amount: string;
+ transactionType: 'income' | 'expense';
+ category: string;
+};
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 7a2b838..a09f366 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,7 +9,8 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
- "types": ["cypress-plugin-steps"],
+ "types": ["cypress-plugin-steps",
+ "@testing-library/jest-dom"],
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
diff --git a/vitest.config.mts b/vitest.config.mts
new file mode 100644
index 0000000..f6e65a3
--- /dev/null
+++ b/vitest.config.mts
@@ -0,0 +1,13 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+export default defineConfig({
+ plugins: [tsconfigPaths(), react()],
+ test: {
+ coverage: { enabled: true },
+ environment: "jsdom",
+ setupFiles: "./vitest.setup.ts",
+ globals: true,
+ },
+});
diff --git a/vitest.setup.ts b/vitest.setup.ts
new file mode 100644
index 0000000..d9c3875
--- /dev/null
+++ b/vitest.setup.ts
@@ -0,0 +1,15 @@
+import '@testing-library/jest-dom/vitest';
+import { beforeAll } from 'vitest';
+beforeAll(() => {
+ global.ResizeObserver = class ResizeObserver {
+ observe() {
+ // do nothing
+ }
+ unobserve() {
+ // do nothing
+ }
+ disconnect() {
+ // do nothing
+ }
+ };
+});
\ No newline at end of file