diff --git a/components.json b/components.json new file mode 100644 index 0000000..edcaef2 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..2e1fd93 --- /dev/null +++ b/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + images: { + domains: ["images.unsplash.com", "randomuser.me"], // external image domains + }, +}; + +module.exports = nextConfig; diff --git a/next.config.ts b/next.config.ts deleted file mode 100644 index 156ac95..0000000 --- a/next.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - /* config options here */ - images:{ - domains: ['images.unsplash.com','randomuser.me'], - } -}; - -export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 3fdb753..7dda71c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,14 @@ "name": "frontend_dev_assignment", "version": "0.1.0", "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.544.0", "next": "15.5.4", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "swr": "^2.3.6", + "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -21,6 +26,7 @@ "eslint": "^9", "eslint-config-next": "15.5.4", "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", "typescript": "^5" } }, @@ -2301,12 +2307,33 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2478,6 +2505,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", @@ -4453,6 +4489,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -5641,6 +5686,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", @@ -5772,6 +5840,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5948,6 +6026,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 252da23..35ddb17 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,25 @@ "lint": "eslint" }, "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.544.0", + "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.4" + "swr": "^2.3.6", + "tailwind-merge": "^3.3.1" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.5.4", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "typescript": "^5" } } diff --git a/src/app/globals.css b/src/app/globals.css index d4b5078..d3f10e6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1 +1,120 @@ @import 'tailwindcss'; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..4dc2872 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Navbar from "../components/Navbar"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,7 +28,8 @@ export default function RootLayout({ - {children} + +
{children}
); diff --git a/src/app/page.tsx b/src/app/page.tsx index 23eaf49..c4bbdc5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,57 +1,65 @@ 'use client' +import { useState } from 'react' +import useSWR from 'swr' import { WorkerType } from '@/types/workers' -import Image from 'next/image' -import { useState, useEffect } from 'react' +import WorkersGrid from '@/components/WorkersGrid' +import Pagination from '@/components/Pagination' +import Filters from '@/components/Filters'; + +// import Filters from '@/components/filters' + + +const fetcher = async (url: string): Promise<{ data: WorkerType[] }> => { + const res = await fetch(url) + if (!res.ok) throw new Error('Failed to fetch workers') + return res.json() +} export default function WorkersPage() { - const [workersData, setWorkersData] = useState([]) - - useEffect(() => { - const loadData = async () => { - try { - const response = await import('../../workers.json') - setWorkersData(response.default) - } catch (error) { - console.error('Failed to load workers:', error) - } - } - loadData() - loadData() - }, []) + const [currentPage, setCurrentPage] = useState(1) + const [priceFilter, setPriceFilter] = useState('all') + const [serviceFilter, setServiceFilter] = useState('all') + const itemsPerPage = 9 + + const { data, error, isLoading } = useSWR('/api/workers', fetcher, { + revalidateOnFocus: false, + dedupingInterval: 60_000 + }) + + const workersData: WorkerType[] = data?.data || [] + + const filteredData = workersData + .filter(worker => { + const effectivePrice = Math.round(worker.pricePerDay * 1.18) + if (priceFilter === 'low') return effectivePrice < 500 + if (priceFilter === 'mid') return effectivePrice >= 500 && effectivePrice <= 1000 + if (priceFilter === 'high') return effectivePrice > 1000 + return true + }) + .filter(worker => serviceFilter === 'all' || worker.service.toLowerCase() === serviceFilter.toLowerCase()) + + const startIndex = (currentPage - 1) * itemsPerPage + const endIndex = startIndex + itemsPerPage + const paginatedData = filteredData.slice(startIndex, endIndex) + const totalPages = Math.ceil(filteredData.length / itemsPerPage) return ( -
-

Our Workers

- -
- {workersData - .filter((worker) => worker.pricePerDay > 0) - .filter((worker) => worker.id !== null) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((worker: WorkerType) => ( -
-
- {worker.name} -
-
-

{worker.name}

-

{worker.service}

-

- ₹{Math.round(worker.pricePerDay * 1.18)} / day -

-
-
- ))} -
+
+ + + + +
) } diff --git a/src/components/Filters.tsx b/src/components/Filters.tsx new file mode 100644 index 0000000..53c5e7c --- /dev/null +++ b/src/components/Filters.tsx @@ -0,0 +1,57 @@ +'use client' +import { ChevronDown } from 'lucide-react' + +interface FiltersProps { + priceFilter: string + serviceFilter: string + setPriceFilter: (value: string) => void + setServiceFilter: (value: string) => void + setCurrentPage: (value: number) => void +} + +export default function Filters({ priceFilter, serviceFilter, setPriceFilter, setServiceFilter, setCurrentPage }: FiltersProps) { + const handlePriceChange = (value: string) => { setPriceFilter(value); setCurrentPage(1) } + const handleServiceChange = (value: string) => { setServiceFilter(value); setCurrentPage(1) } + + return ( +
+

Our Workers

+ +
+ {/* Price Filter */} +
+ +
+ +
+
+ + {/* Service Filter */} +
+ +
+ +
+
+
+
+ ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..085c7fe --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,43 @@ +'use client' +import { useState } from 'react' +import Link from 'next/link' +import { Menu, X } from 'lucide-react' + +export default function Navbar() { + const [isOpen, setIsOpen] = useState(false) + + return ( + + ) +} diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..43e9ffe --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,75 @@ +'use client' + +interface PaginationProps { + currentPage: number + totalPages: number + setCurrentPage: React.Dispatch> +} + +export default function Pagination({ currentPage, totalPages, setCurrentPage }: PaginationProps) { + return ( +
+ + + {(() => { + const pages = [] + const maxButtons = 5 + const showEllipsis = totalPages > maxButtons + 2 + const start = Math.max(2, currentPage - 2) + const end = Math.min(totalPages - 1, currentPage + 2) + + pages.push( + + ) + + if (showEllipsis && start > 2) pages.push() + + for (let i = start; i <= end; i++) { + pages.push( + + ) + } + + if (showEllipsis && end < totalPages - 1) pages.push() + + if (totalPages > 1) pages.push( + + ) + + return pages + })()} + + +
+ ) +} diff --git a/src/components/WorkerCard.tsx b/src/components/WorkerCard.tsx new file mode 100644 index 0000000..a915078 --- /dev/null +++ b/src/components/WorkerCard.tsx @@ -0,0 +1,31 @@ +import { WorkerType } from '@/types/workers' +import React from 'react' + +const WorkerCard = React.memo(({ worker }: { worker: WorkerType }) => { + return ( +
+
+ {worker.name} +
+
+

{worker.name}

+

{worker.service}

+

+ ₹{Math.round(worker.pricePerDay * 1.18)} / day +

+
+
+ ) +}) + +WorkerCard.displayName = 'WorkerCard' +export default WorkerCard diff --git a/src/components/WorkerCardSkeleton.tsx b/src/components/WorkerCardSkeleton.tsx new file mode 100644 index 0000000..fb2fc9e --- /dev/null +++ b/src/components/WorkerCardSkeleton.tsx @@ -0,0 +1,12 @@ +export default function WorkerCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/WorkersGrid.tsx b/src/components/WorkersGrid.tsx new file mode 100644 index 0000000..71967ec --- /dev/null +++ b/src/components/WorkersGrid.tsx @@ -0,0 +1,25 @@ +'use client' +import { WorkerType } from '@/types/workers' +import WorkerCard from './WorkerCard' +import WorkerCardSkeleton from './WorkerCardSkeleton' + +interface WorkersGridProps { + workers: WorkerType[] + isLoading: boolean + error: Error | undefined +} + +export default function WorkersGrid({ workers, isLoading, error }: WorkersGridProps) { + return ( +
+ {isLoading + ? Array.from({ length: 9 }).map((_, i) => ) + : error + ?

Failed to load workers. Please try again later.

+ : workers + .filter(w => w.pricePerDay > 0 && w.id !== null) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(worker => )} +
+ ) +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..32ea0ef --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/tsconfig.json b/tsconfig.json index c133409..bff9b53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next.config.js"], "exclude": ["node_modules"] }