diff --git a/README.md b/README.md index 4389483..fc130a0 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,31 @@ This assignment is designed to assess your practical skills in **React, Next.js, - Add comment for any **bug fix or optimization.** - Document any **extra improvements** you make in your submission. -Good luck 🚀 +## How to run locally + +```bash +npm install +npm run dev +``` +Node version: 18.x+ + +## What I implemented + +- Responsive card grid and improved card UI +- Sticky, responsive navbar +- Pagination and filters for workers +- API integration with loading/error states +- Performance optimizations (lazy loading, memoization) +- Unit/component tests + +## Trade-offs / Known Issues + +- [List any limitations or decisions you made] + +## How to run tests + +```bash +npm test +``` + +Good luck 🚀 diff --git a/components/Filters.tsx b/components/Filters.tsx new file mode 100644 index 0000000..397bd26 --- /dev/null +++ b/components/Filters.tsx @@ -0,0 +1,26 @@ +// components/Filters.tsx +import React from 'react'; + +export default function Filters({ onTypeChange, onPriceChange }:{ onTypeChange:(v:string|null)=>void, onPriceChange:(r:[number,number]|null)=>void }) { + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/components/Navbar.tsx b/components/Navbar.tsx new file mode 100644 index 0000000..1fcee89 --- /dev/null +++ b/components/Navbar.tsx @@ -0,0 +1,17 @@ +// components/Navbar.tsx +import Link from 'next/link'; + +export default function Navbar() { + return ( +
+
+ SolveEase + +
+
+ ); +} \ No newline at end of file diff --git a/components/Pagination.tsx b/components/Pagination.tsx new file mode 100644 index 0000000..b8dc781 --- /dev/null +++ b/components/Pagination.tsx @@ -0,0 +1,22 @@ +// components/Pagination.tsx +import React from 'react'; + +export default function Pagination({ current, total, onChange } : { current:number, total:number, onChange:(n:number)=>void }) { + const pages = Array.from({length: total}, (_,i)=>i+1); + return ( + + ); +} \ No newline at end of file diff --git a/components/SkeltonGrid.tsx b/components/SkeltonGrid.tsx new file mode 100644 index 0000000..d9e4fc0 --- /dev/null +++ b/components/SkeltonGrid.tsx @@ -0,0 +1,17 @@ +// components/SkeletonGrid.tsx +import React from 'react'; + +export default function SkeletonGrid({ cols=3, rows=3 }:{cols?:number, rows?:number}) { + const items = Array.from({length: cols*rows}); + return ( +
+ {items.map((_,i) => ( +
+
+
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/components/WorkerCard.tsx b/components/WorkerCard.tsx new file mode 100644 index 0000000..1458e34 --- /dev/null +++ b/components/WorkerCard.tsx @@ -0,0 +1,27 @@ +// components/WorkerCard.tsx +import React from 'react'; +import Image from 'next/image'; + +export default function WorkerCard({ worker }:{ worker:any }) { + return ( +
+
+ {worker.name} +
+
+

{worker.name}

+

{worker.description}

+
+ ₹{worker.price}/day + +
+
+
+ ); +} \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..74ca942 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + testEnvironment: 'jsdom', + transform: { '^.+\\.[tj]sx?$': 'ts-jest' }, + moduleNameMapper: { + '\\.(css|scss)$': 'identity-obj-proxy', + }, +}; \ No newline at end of file diff --git a/src/_tests_/WorkerCard.tsx b/src/_tests_/WorkerCard.tsx new file mode 100644 index 0000000..25f125d --- /dev/null +++ b/src/_tests_/WorkerCard.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import WorkerCard from "../app/page"; // adjust import if WorkerCard is in a separate file + +const worker = { + id: 1, + name: "John Doe", + service: "Plumber", + pricePerDay: 500, + image: "/john.jpg", +}; + +describe("WorkerCard", () => { + it("renders worker details", () => { + render(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("Plumber")).toBeInTheDocument(); + expect(screen.getByText("₹500/day")).toBeInTheDocument(); + expect(screen.getByAltText("John Doe")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/app/Navbar.tsx b/src/app/Navbar.tsx new file mode 100644 index 0000000..620d87e --- /dev/null +++ b/src/app/Navbar.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import Link from "next/link"; + +export default function Navbar() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/api/workers/route.ts b/src/app/api/workers/route.ts index 44a245e..de5d866 100644 --- a/src/app/api/workers/route.ts +++ b/src/app/api/workers/route.ts @@ -1,18 +1,18 @@ -import { NextResponse } from 'next/server' -import workersData from '../../../../workers.json' +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; export async function GET() { try { - return NextResponse.json({ - success: true, - data: workersData - }) - } catch (error) { - console.error('API Error:', error) - return NextResponse.json({ - success: false, - error: 'Failed to fetch workers data' - }, { status: 500 }) + // adjust path if your workers.json lives somewhere else + const filePath = path.join(process.cwd(), 'data', 'workers.json'); + const raw = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + // optionally: support query params for paging/filters here + return NextResponse.json({ ok: true, data }); + } catch (err) { + console.error('API /api/wprkers error:', err); + return NextResponse.json({ ok: false, error: 'Failed to load workers' }, { status: 500 }); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..b32ff94 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 "./Navbar"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,6 +28,7 @@ export default function RootLayout({ + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 23eaf49..5ab6eab 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,57 +1,206 @@ -'use client' -import { WorkerType } from '@/types/workers' -import Image from 'next/image' -import { useState, useEffect } from 'react' +"use client"; +import React, { useEffect, useState, useMemo } from "react"; +import { WorkerType } from "../types/workers"; +import Image from "next/image"; -export default function WorkersPage() { - const [workersData, setWorkersData] = useState([]) +const PAGE_SIZE = 9; +function SkeletonCard() { + return ( +
+
+
+
+
+ ); +} + +function WorkerCard({ worker }: { worker: WorkerType }) { + return ( +
+
+ {worker.name} +
+

{worker.name}

+

{worker.service}

+ + ₹{worker.pricePerDay}/day + +
+ ); +} + +export default function HomePage() { + const [workers, setWorkers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Pagination + const [page, setPage] = useState(1); + + // Filters + const [serviceFilter, setServiceFilter] = useState(""); + const [priceFilter, setPriceFilter] = useState<[number, number] | null>(null); + + // Fetch workers from API useEffect(() => { - const loadData = async () => { - try { - const response = await import('../../workers.json') - setWorkersData(response.default) - } catch (error) { - console.error('Failed to load workers:', error) - } + setLoading(true); + setError(null); + fetch("/api/workers") + .then((res) => res.json()) + .then((data) => { + if (data.success) { + setWorkers(data.data); + } else { + setError("Failed to fetch workers."); + } + }) + .catch(() => setError("Network error.")) + .finally(() => setLoading(false)); + }, []); + + // Memoized filtered workers + const filteredWorkers = useMemo(() => { + let result = workers; + if (serviceFilter) { + result = result.filter((w) => w.service === serviceFilter); } - loadData() - loadData() - }, []) + if (priceFilter) { + result = result.filter( + (w) => w.pricePerDay >= priceFilter[0] && w.pricePerDay <= priceFilter[1] + ); + } + return result; + }, [workers, serviceFilter, priceFilter]); + + // Pagination logic + const totalPages = Math.ceil(filteredWorkers.length / PAGE_SIZE); + const paginatedWorkers = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredWorkers.slice(start, start + PAGE_SIZE); + }, [filteredWorkers, page]); + + // Get unique services for filter dropdown + const serviceOptions = useMemo( + () => Array.from(new Set(workers.map((w) => w.service))), + [workers] + ); + + // Get min/max price for price filter + const minPrice = useMemo( + () => Math.min(...workers.map((w) => w.pricePerDay)), + [workers] + ); + const maxPrice = useMemo( + () => Math.max(...workers.map((w) => w.pricePerDay)), + [workers] + ); + + // Handle filter changes + function handleServiceChange(e: React.ChangeEvent) { + setServiceFilter(e.target.value); + setPage(1); + } + function handlePriceChange(e: React.ChangeEvent, type: "min" | "max") { + const value = Number(e.target.value); + setPriceFilter((prev) => { + if (!prev) return type === "min" ? [value, maxPrice] : [minPrice, value]; + return type === "min" ? [value, prev[1]] : [prev[0], value]; + }); + setPage(1); + } 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 -

-
-
+
+

Find a Worker

+ {/* Filters */} +
+ +
+ Price/Day: + handlePriceChange(e, "min")} + className="border rounded px-2 py-1 w-20" + /> + - + handlePriceChange(e, "max")} + className="border rounded px-2 py-1 w-20" + /> +
+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Card Grid */} +
+ {loading + ? Array.from({ length: PAGE_SIZE }).map((_, i) => ) + : paginatedWorkers.map((worker) => ( + + ))} +
+ + {/* Pagination */} +
+ + + Page {page} of {totalPages} + +
- ) + ); }