From 258a8734ab5cd8415d15ff95dbbd4bc66aebcc63 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 18:05:11 +0530 Subject: [PATCH 01/25] refactor: split components (Navbar, Filters, WorkerCard, SkeletonCard, Pagination) --- src/app/components/Filters.tsx | 0 src/app/components/Navbar.tsx | 0 src/app/components/Pagination.tsx | 0 src/app/components/SkeletonCard.tsx | 0 src/app/components/WorkerCard.tsx | 0 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/app/components/Filters.tsx create mode 100644 src/app/components/Navbar.tsx create mode 100644 src/app/components/Pagination.tsx create mode 100644 src/app/components/SkeletonCard.tsx create mode 100644 src/app/components/WorkerCard.tsx diff --git a/src/app/components/Filters.tsx b/src/app/components/Filters.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/Pagination.tsx b/src/app/components/Pagination.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/SkeletonCard.tsx b/src/app/components/SkeletonCard.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx new file mode 100644 index 0000000..e69de29 From 03f64b9f6fba0bd3c1a8b981e6890fe6d1f63549 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 18:07:59 +0530 Subject: [PATCH 02/25] chore(deps): install lucide-react, clsx, tailwind-merge --- package-lock.json | 33 ++++++++++++++++++++++++++++++++- package.json | 13 ++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3fdb753..50e8901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "frontend_dev_assignment", "version": "0.1.0", "dependencies": { + "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", + "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -2307,6 +2310,15 @@ "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", @@ -4453,6 +4465,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 +5662,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", diff --git a/package.json b/package.json index 252da23..14cd481 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,22 @@ "lint": "eslint" }, "dependencies": { + "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" + "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", + "typescript": "^5" } } From 7acd227cbcc227d42096b005f7b2a01ad1b0175a Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 18:59:30 +0530 Subject: [PATCH 03/25] feat(ui): add responsive Navbar component with mobile menu --- src/app/components/Navbar.tsx | 121 ++++++++++++++++++++++++++++++++++ src/app/page.tsx | 2 + 2 files changed, 123 insertions(+) diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index e69de29..0835d59 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -0,0 +1,121 @@ +// ============================================================================ +// NAVBAR COMPONENT +// ============================================================================ + +import React, { useState, useEffect, memo } from "react"; +import { + Menu, + Home, + Users, + Info, + Phone, +} from "lucide-react"; + +const Navbar = memo(() => { + const [isScrolled, setIsScrolled] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 10); + }; + + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + return ( + + ); +}); + +Navbar.displayName = "Navbar"; +export default Navbar; \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 23eaf49..08169ac 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import { WorkerType } from '@/types/workers' import Image from 'next/image' import { useState, useEffect } from 'react' +import Navbar from "@/app/components/Navbar"; export default function WorkersPage() { const [workersData, setWorkersData] = useState([]) @@ -21,6 +22,7 @@ export default function WorkersPage() { return (
+

Our Workers

From a363643c37c572f16e9cf1861468ae97a7b14611 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 19:43:48 +0530 Subject: [PATCH 04/25] feat(ui): add WorkerCard component and integrate into WorkersPage for responsive grid --- src/app/components/WorkerCard.tsx | 74 +++++++ src/app/page.tsx | 320 ++++++++++++++++++++++++++---- 2 files changed, 355 insertions(+), 39 deletions(-) diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx index e69de29..4d2e0d6 100644 --- a/src/app/components/WorkerCard.tsx +++ b/src/app/components/WorkerCard.tsx @@ -0,0 +1,74 @@ + +// ============================================================================ +// WORKER CARD COMPONENT +// ============================================================================ + +import React, { useState, memo } from "react"; +import Image from "next/image"; +import { WorkerType } from "@/types/workers"; +import { + AlertCircle, + Loader2, + +} from "lucide-react"; + +const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageError, setImageError] = useState(false); + + return ( +
+
+ {!imageLoaded && !imageError && ( +
+ +
+ )} + {imageError ? ( +
+ + Image not available +
+ ) : ( + {worker.name} setImageLoaded(true)} + onError={() => setImageError(true)} + loading="lazy" + sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" + /> + )} +
+
+

+ {worker.name} +

+

+ {worker.service} +

+
+
+ + ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} + + / day +
+ + +18% GST + +
+ +
+
+ ); +}); + +WorkerCard.displayName = "WorkerCard"; +export default WorkerCard; diff --git a/src/app/page.tsx b/src/app/page.tsx index 08169ac..9ef14f5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,37 @@ -'use client' -import { WorkerType } from '@/types/workers' -import Image from 'next/image' -import { useState, useEffect } from 'react' +"use client"; + +import React, { useState, useEffect, useMemo, useCallback, } from "react"; +import { WorkerType } from "@/types/workers"; +import WorkerCard from "@/app/components/WorkerCard"; import Navbar from "@/app/components/Navbar"; +import { + AlertCircle, +} from "lucide-react"; + +// ============================================================================ +// INTERFACES AND TYPES +// ============================================================================ + +interface FilterState { + service: string; + minPrice: number; + maxPrice: number; + searchQuery: string; +} + +interface ApiResponse { + success: boolean; + data?: WorkerType[]; + error?: string; +} + +// MAIN WORKERS PAGE COMPONENT export default function WorkersPage() { + + // LEGACY CODE (COMMENTED OUT - DON'T DELETE) + + /* const [workersData, setWorkersData] = useState([]) useEffect(() => { @@ -17,43 +44,258 @@ export default function WorkersPage() { } } loadData() - loadData() + loadData() // This was a duplicate call - bug fixed }, []) + */ - 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) => ( -
([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + service: "", + minPrice: 0, + maxPrice: 1000, + searchQuery: "", + }); + + const itemsPerPage = 1000 + + // API INTEGRATION WITH CACHING + + const fetchWorkers = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // Check cache first + const cacheKey = "workers_data"; + const cacheTimestamp = "workers_timestamp"; + const cacheExpiry = 5 * 60 * 1000; // 5 minutes + + const cachedData = localStorage.getItem(cacheKey); + const cachedTime = localStorage.getItem(cacheTimestamp); + + if (cachedData && cachedTime) { + const isValid = Date.now() - parseInt(cachedTime) < cacheExpiry; + if (isValid) { + const parsedData = JSON.parse(cachedData); + setWorkers(parsedData); + setLoading(false); + return; + } + } + + const response = await fetch("/api/workers", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result: ApiResponse = await response.json(); + + if (!result.success) { + throw new Error(result.error || "Failed to fetch workers"); + } + + const validWorkers = (result.data || []).filter( + (worker) => worker.id !== null && worker.pricePerDay > 0 + ); + + setWorkers(validWorkers); + + // Cache the data + localStorage.setItem(cacheKey, JSON.stringify(validWorkers)); + localStorage.setItem(cacheTimestamp, Date.now().toString()); + } catch (err) { + console.error("Error fetching workers:", err); + setError( + err instanceof Error ? err.message : "An unknown error occurred" + ); + + // Fallback to cached data if available + const cachedData = localStorage.getItem("workers_data"); + if (cachedData) { + try { + setWorkers(JSON.parse(cachedData)); + setError("Using cached data due to network error"); + } catch { + // Ignore parsing errors + } + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (showFilters) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [showFilters]); + + useEffect(() => { + fetchWorkers(); + }, [fetchWorkers]); + + // COMPUTED VALUES WITH MEMOIZATION + + const { + filteredWorkers, + paginatedWorkers, + totalPages, + } = useMemo(() => { + // Get unique services + const services = Array.from( + new Set(workers.map((worker) => worker.service)) + ).sort(); + + // Calculate price range + const prices = workers.map((w) => Math.round(w.pricePerDay * 1.18)); + const range = { + min: Math.min(...prices) || 0, + max: Math.max(...prices) || 1000, + }; + + // Filter workers + let filtered = workers.filter((worker) => { + const matchesService = + !filters.service || + worker.service.toLowerCase() === filters.service.toLowerCase(); + const price = Math.round(worker.pricePerDay * 1.18); + const matchesPrice = + price >= filters.minPrice && price <= filters.maxPrice; + const matchesSearch = + !filters.searchQuery || + worker.name.toLowerCase().includes(filters.searchQuery.toLowerCase()) || + worker.service + .toLowerCase() + .includes(filters.searchQuery.toLowerCase()); + + return matchesService && matchesPrice && matchesSearch; + }); + + // Sort by name + filtered = filtered.sort((a, b) => a.name.localeCompare(b.name)); + + // Calculate pagination + const totalPages = Math.ceil(filtered.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginated = filtered.slice(startIndex, startIndex + itemsPerPage); + + return { + filteredWorkers: filtered, + paginatedWorkers: paginated, + totalPages, + uniqueServices: services, + priceRange: range, + }; + }, [workers, filters, currentPage, itemsPerPage]); + + + + // Reset page when filters change + useEffect(() => { + setCurrentPage(1); + }, [filters]); + + + + // ERROR STATE + + if (error && workers.length === 0) { + return ( +
+ +
+
+ +

+ Unable to load workers data +

+

{error}

+ +
+
+
+ ); + } + + // MAIN RENDER + + return ( +
+ + +
+ {/* Header Section */} +
+

+ Find Skilled Workers +

+

+ Browse through our verified professionals and find the perfect + worker for your needs +

+ {error && workers.length > 0 && ( +
+ {error} +
+ )} +
+ + + + {/* Workers Grid */} +
+ {/* Results Info */} + {!loading && ( +
+

+ Showing {paginatedWorkers.length} of {filteredWorkers.length}{" "} + workers + {filters.service && ( + in {filters.service} + )}

+
+ Page {currentPage} of {totalPages} +
-
- ))} -
-
- ) -} + )} + + + + {/* Workers Grid */} + {!loading && paginatedWorkers.length > 0 && ( +
+ {paginatedWorkers.map((worker) => ( + + ))} +
+ )} + + +
+
+ + ); +} \ No newline at end of file From 595909789ae93d45d164bee8c8b212616b35bbb7 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 20:00:37 +0530 Subject: [PATCH 05/25] feat(ui): add Pagination component and integrate into WorkersPage --- src/app/components/Pagination.tsx | 97 +++++++++++++++++++++++++++++++ src/app/page.tsx | 58 ++++++++++++++---- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/src/app/components/Pagination.tsx b/src/app/components/Pagination.tsx index e69de29..9d09175 100644 --- a/src/app/components/Pagination.tsx +++ b/src/app/components/Pagination.tsx @@ -0,0 +1,97 @@ +// ============================================================================ +// PAGINATION COMPONENT +// ============================================================================ + +import React, { useCallback, memo } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +const Pagination = memo( + ({ + currentPage, + totalPages, + onPageChange, + }: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + }) => { + const getVisiblePages = useCallback(() => { + const delta = 2; + const range = []; + const rangeWithDots = []; + + for ( + let i = Math.max(2, currentPage - delta); + i <= Math.min(totalPages - 1, currentPage + delta); + i++ + ) { + range.push(i); + } + + if (currentPage - delta > 2) { + rangeWithDots.push(1, "..."); + } else { + rangeWithDots.push(1); + } + + rangeWithDots.push(...range); + + if (currentPage + delta < totalPages - 1) { + rangeWithDots.push("...", totalPages); + } else if (totalPages > 1) { + rangeWithDots.push(totalPages); + } + + return rangeWithDots; + }, [currentPage, totalPages]); + + if (totalPages <= 1) return null; + + return ( +
+ + + {getVisiblePages().map((page, index) => ( + + {page === "..." ? ( + ... + ) : ( + + )} + + ))} + + +
+ ); + } +); + +Pagination.displayName = "Pagination"; + +export default Pagination; diff --git a/src/app/page.tsx b/src/app/page.tsx index 9ef14f5..de9fa4a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,12 @@ "use client"; -import React, { useState, useEffect, useMemo, useCallback, } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { WorkerType } from "@/types/workers"; -import WorkerCard from "@/app/components/WorkerCard"; -import Navbar from "@/app/components/Navbar"; +import WorkerCard from "./components/WorkerCard" +import Navbar from "./components/Navbar" +import Pagination from "./components/Pagination"; import { + Search, AlertCircle, } from "lucide-react"; @@ -62,7 +64,7 @@ export default function WorkersPage() { searchQuery: "", }); - const itemsPerPage = 1000 + const itemsPerPage = 12; // API INTEGRATION WITH CACHING @@ -157,7 +159,8 @@ export default function WorkersPage() { filteredWorkers, paginatedWorkers, totalPages, - } = useMemo(() => { + priceRange, + } = useMemo(() => { // Get unique services const services = Array.from( new Set(workers.map((worker) => worker.service)) @@ -205,14 +208,39 @@ export default function WorkersPage() { }; }, [workers, filters, currentPage, itemsPerPage]); - + // Update price range in filters when workers data changes + useEffect(() => { + if (workers.length > 0) { + setFilters((prev) => { + // If user has not changed defaults, set to API range + if (prev.minPrice === 0 && prev.maxPrice === 1000) { + return { + ...prev, + minPrice: priceRange.min, + maxPrice: priceRange.max, + }; + } + // otherwise preserve user's values but clamp them within the new range + return { + ...prev, + minPrice: Math.max(priceRange.min, prev.minPrice), + maxPrice: Math.min(priceRange.max, prev.maxPrice), + }; + }); + } + }, [workers.length, priceRange.min, priceRange.max]); // Reset page when filters change useEffect(() => { setCurrentPage(1); }, [filters]); - + // EVENT HANDLERS + + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); // ERROR STATE @@ -261,8 +289,7 @@ export default function WorkersPage() { )} - - + {/* Workers Grid */}
@@ -282,7 +309,7 @@ export default function WorkersPage() {
)} - + {/* Workers Grid */} {!loading && paginatedWorkers.length > 0 && ( @@ -293,8 +320,17 @@ export default function WorkersPage() { )} + - + {/* Pagination */} + {!loading && ( + + )} + ); From 78e2059c3ebf0bdd23274dc91b222038d411a6ff Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 20:24:18 +0530 Subject: [PATCH 06/25] feat(ui): add Filters component and integrate into WorkersPage --- src/app/components/Filters.tsx | 223 +++++++++++++++++++++++++++++++++ src/app/page.tsx | 87 ++++++++++--- 2 files changed, 291 insertions(+), 19 deletions(-) diff --git a/src/app/components/Filters.tsx b/src/app/components/Filters.tsx index e69de29..0a66cf3 100644 --- a/src/app/components/Filters.tsx +++ b/src/app/components/Filters.tsx @@ -0,0 +1,223 @@ + +// ============================================================================ +// FILTERS COMPONENT +// ============================================================================ + +import React, { useEffect, useCallback, memo } from "react"; + +import { + Search, + X, +} from "lucide-react"; + +import { FilterState } from "@/types/workers"; + +const Filters = memo( + ({ + filters, + onFiltersChange, + services, + priceRange, + isVisible, + onClose, + closeOnOutsideClick = true, + }: { + filters: FilterState; + onFiltersChange: (filters: FilterState) => void; + services: string[]; + priceRange: { min: number; max: number }; + isVisible: boolean; + onClose: () => void; + closeOnOutsideClick?: boolean; + }) => { + const handleFilterChange = useCallback( + (key: keyof FilterState, value: string | number) => { + onFiltersChange({ ...filters, [key]: value }); + }, + [filters, onFiltersChange] + ); + + // Close with ESC + useEffect(() => { + if (!isVisible) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isVisible, onClose]); + + if (!isVisible) return null; + + return ( +
+ {/* Panel */} +
e.stopPropagation()} + > +
+
+

Filters

+ +
+ +
+ {/* Search */} +
+ +
+ + + handleFilterChange("searchQuery", e.target.value) + } + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + /> +
+
+ + {/* Service Filter */} +
+ + +
+ + {/* Price Range */} +
+ +
+
+ + + handleFilterChange("minPrice", Number(e.target.value)) + } + className="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + /> +
+
+ + + handleFilterChange("maxPrice", Number(e.target.value)) + } + className="w-full p-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" + /> +
+
+
+ Range: ₹{priceRange.min.toLocaleString()} - ₹ + {priceRange.max.toLocaleString()} +
+
+ + {/* Active Filters */} + {(filters.service || + filters.searchQuery || + filters.minPrice !== priceRange.min || + filters.maxPrice !== priceRange.max) && ( +
+ +
+ {filters.service && ( +
+ Service: {filters.service} + +
+ )} + {filters.searchQuery && ( +
+ Search: {filters.searchQuery} + +
+ )} +
+
+ )} + + {/* Clear Filters */} + + + {/* Done button (mobile only) */} + +
+
+
+
+ ); + } +); +Filters.displayName = "Filters"; + +export default Filters; \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index de9fa4a..577f17d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,27 +5,15 @@ import { WorkerType } from "@/types/workers"; import WorkerCard from "./components/WorkerCard" import Navbar from "./components/Navbar" import Pagination from "./components/Pagination"; +import Filters from "./components/Filters"; +import { FilterState } from "@/types/workers"; +import { ApiResponse } from "@/types/workers"; import { + Filter, Search, AlertCircle, } from "lucide-react"; -// ============================================================================ -// INTERFACES AND TYPES -// ============================================================================ - -interface FilterState { - service: string; - minPrice: number; - maxPrice: number; - searchQuery: string; -} - -interface ApiResponse { - success: boolean; - data?: WorkerType[]; - error?: string; -} // MAIN WORKERS PAGE COMPONENT @@ -159,6 +147,7 @@ export default function WorkersPage() { filteredWorkers, paginatedWorkers, totalPages, + uniqueServices, priceRange, } = useMemo(() => { // Get unique services @@ -237,6 +226,10 @@ export default function WorkersPage() { // EVENT HANDLERS + const handleFiltersChange = useCallback((newFilters: FilterState) => { + setFilters(newFilters); + }, []); + const handlePageChange = useCallback((page: number) => { setCurrentPage(page); window.scrollTo({ top: 0, behavior: "smooth" }); @@ -289,7 +282,39 @@ export default function WorkersPage() { )} - + +
+ {/* Filters Sidebar */} +
+ + +
+ setShowFilters(false)} + closeOnOutsideClick={false} + /> +
+ + setShowFilters(false)} + /> +
{/* Workers Grid */}
@@ -309,7 +334,6 @@ export default function WorkersPage() {
)} - {/* Workers Grid */} {!loading && paginatedWorkers.length > 0 && ( @@ -320,7 +344,31 @@ export default function WorkersPage() {
)} - + {/* No Results */} + {!loading && filteredWorkers.length === 0 && ( +
+ +

+ No workers found +

+

+ Try adjusting your filters or search terms +

+ +
+ )} {/* Pagination */} {!loading && ( @@ -331,6 +379,7 @@ export default function WorkersPage() { /> )} + ); From 58d208bb60a68f498ed7d2583804915bba2e600c Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 20:52:00 +0530 Subject: [PATCH 07/25] feat(ui): add SkeletonCard component for loading state --- src/app/components/SkeletonCard.tsx | 17 ++++++++++++++++ src/app/page.tsx | 31 +++++++++++++++++++---------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/app/components/SkeletonCard.tsx b/src/app/components/SkeletonCard.tsx index e69de29..aec5643 100644 --- a/src/app/components/SkeletonCard.tsx +++ b/src/app/components/SkeletonCard.tsx @@ -0,0 +1,17 @@ +// SKELETON CARD COMPONENT + +import { memo } from "react"; + +export const SkeletonCard = memo(() => ( +
+
+
+
+
+
+
+
+
+)); + +SkeletonCard.displayName = "SkeletonCard"; diff --git a/src/app/page.tsx b/src/app/page.tsx index 577f17d..da1cd6d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,24 +2,17 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"; import { WorkerType } from "@/types/workers"; -import WorkerCard from "./components/WorkerCard" -import Navbar from "./components/Navbar" -import Pagination from "./components/Pagination"; -import Filters from "./components/Filters"; +import {Filters, Navbar, Pagination, SkeletonCard, WorkerCard} from './components/index' import { FilterState } from "@/types/workers"; import { ApiResponse } from "@/types/workers"; -import { - Filter, - Search, - AlertCircle, -} from "lucide-react"; +import { Filter, Search, AlertCircle } from "lucide-react"; // MAIN WORKERS PAGE COMPONENT export default function WorkersPage() { - // LEGACY CODE (COMMENTED OUT - DON'T DELETE) + // LEGACY CODE (COMMENTED OUT) /* const [workersData, setWorkersData] = useState([]) @@ -54,7 +47,9 @@ export default function WorkersPage() { const itemsPerPage = 12; + // ============================================================================ // API INTEGRATION WITH CACHING + // ============================================================================ const fetchWorkers = useCallback(async () => { try { @@ -141,7 +136,9 @@ export default function WorkersPage() { fetchWorkers(); }, [fetchWorkers]); + // ============================================================================ // COMPUTED VALUES WITH MEMOIZATION + // ============================================================================ const { filteredWorkers, @@ -224,7 +221,9 @@ export default function WorkersPage() { setCurrentPage(1); }, [filters]); + // ============================================================================ // EVENT HANDLERS + // ============================================================================ const handleFiltersChange = useCallback((newFilters: FilterState) => { setFilters(newFilters); @@ -235,7 +234,9 @@ export default function WorkersPage() { window.scrollTo({ top: 0, behavior: "smooth" }); }, []); + // ============================================================================ // ERROR STATE + // ============================================================================ if (error && workers.length === 0) { return ( @@ -260,7 +261,9 @@ export default function WorkersPage() { ); } + // ============================================================================ // MAIN RENDER + // ============================================================================ return (
@@ -334,6 +337,14 @@ export default function WorkersPage() {
)} + {/* Loading State */} + {loading && ( +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+ )} {/* Workers Grid */} {!loading && paginatedWorkers.length > 0 && ( From 2c3b4289c45c02acb60e19e947c52c44fb797449 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 21:12:14 +0530 Subject: [PATCH 08/25] feat(ui): integrate WorkerCard + Pagination + Filters (responsive) --- src/app/components/Filters.tsx | 6 +----- src/app/components/Navbar.tsx | 7 ++----- src/app/components/Pagination.tsx | 5 +---- src/app/components/WorkerCard.tsx | 5 +---- src/app/components/index.ts | 5 +++++ src/types/workers.ts | 16 +++++++++++++++- 6 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 src/app/components/index.ts diff --git a/src/app/components/Filters.tsx b/src/app/components/Filters.tsx index 0a66cf3..5517d82 100644 --- a/src/app/components/Filters.tsx +++ b/src/app/components/Filters.tsx @@ -1,7 +1,5 @@ -// ============================================================================ // FILTERS COMPONENT -// ============================================================================ import React, { useEffect, useCallback, memo } from "react"; @@ -12,7 +10,7 @@ import { import { FilterState } from "@/types/workers"; -const Filters = memo( +export const Filters = memo( ({ filters, onFiltersChange, @@ -219,5 +217,3 @@ const Filters = memo( } ); Filters.displayName = "Filters"; - -export default Filters; \ No newline at end of file diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index 0835d59..7da7d20 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -1,6 +1,4 @@ -// ============================================================================ // NAVBAR COMPONENT -// ============================================================================ import React, { useState, useEffect, memo } from "react"; import { @@ -11,7 +9,7 @@ import { Phone, } from "lucide-react"; -const Navbar = memo(() => { +export const Navbar = memo(() => { const [isScrolled, setIsScrolled] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -117,5 +115,4 @@ const Navbar = memo(() => { ); }); -Navbar.displayName = "Navbar"; -export default Navbar; \ No newline at end of file +Navbar.displayName = "Navbar"; \ No newline at end of file diff --git a/src/app/components/Pagination.tsx b/src/app/components/Pagination.tsx index 9d09175..f99bc1e 100644 --- a/src/app/components/Pagination.tsx +++ b/src/app/components/Pagination.tsx @@ -1,11 +1,9 @@ -// ============================================================================ // PAGINATION COMPONENT -// ============================================================================ import React, { useCallback, memo } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; -const Pagination = memo( +export const Pagination = memo( ({ currentPage, totalPages, @@ -94,4 +92,3 @@ const Pagination = memo( Pagination.displayName = "Pagination"; -export default Pagination; diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx index 4d2e0d6..b65ccfb 100644 --- a/src/app/components/WorkerCard.tsx +++ b/src/app/components/WorkerCard.tsx @@ -1,7 +1,5 @@ -// ============================================================================ // WORKER CARD COMPONENT -// ============================================================================ import React, { useState, memo } from "react"; import Image from "next/image"; @@ -12,7 +10,7 @@ import { } from "lucide-react"; -const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { +export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); @@ -71,4 +69,3 @@ const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { }); WorkerCard.displayName = "WorkerCard"; -export default WorkerCard; diff --git a/src/app/components/index.ts b/src/app/components/index.ts new file mode 100644 index 0000000..0c1e2a0 --- /dev/null +++ b/src/app/components/index.ts @@ -0,0 +1,5 @@ +export * from './Filters' +export * from './Navbar' +export * from './Pagination' +export * from './SkeletonCard' +export * from './WorkerCard' \ No newline at end of file diff --git a/src/types/workers.ts b/src/types/workers.ts index 7cd826b..771244f 100644 --- a/src/types/workers.ts +++ b/src/types/workers.ts @@ -4,4 +4,18 @@ export interface WorkerType { service: string pricePerDay: number image: string -} \ No newline at end of file +} + +export interface FilterState { + service: string; + minPrice: number; + maxPrice: number; + searchQuery: string; +} + + +export interface ApiResponse { + success: boolean; + data?: WorkerType[]; + error?: string; +} From 15c9b2188a34a4bc67be0968535c658fdb20c61a Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 21:28:58 +0530 Subject: [PATCH 09/25] chore: prepare WorkerHub for deployment --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..2cc2ba0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,7 +13,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", + title: "WorkerHub", description: "Generated by create next app", }; From 021caedaf35c32ef9d272d8d7f12b4a49d749e4a Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 21:38:09 +0530 Subject: [PATCH 10/25] fix(images): bypass next/image optimization for remote avatars (unoptimized) --- src/app/components/WorkerCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx index b65ccfb..3a4c154 100644 --- a/src/app/components/WorkerCard.tsx +++ b/src/app/components/WorkerCard.tsx @@ -32,6 +32,7 @@ export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { src={worker.image} alt={worker.name} fill + unoptimized className={`object-cover group-hover:scale-105 transition-transform duration-300 ${ imageLoaded ? "opacity-100" : "opacity-0" }`} From cce76252429ec8f242d303c739d092ea949534fb Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 22:38:40 +0530 Subject: [PATCH 11/25] Updated Readme --- README.md | 87 ------------------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index 4389483..0000000 --- a/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Frontend Developer Intern Assignment - -## Mandatory Tasks -- Follow SolveEase on [Github](https://github.com/solve-ease) and [Linkedin](https://www.linkedin.com/company/solve-ease) -- Star this repo - -## Objective -This assignment is designed to assess your practical skills in **React, Next.js, TypeScript, Tailwind CSS, and frontend optimizations**. You will work on an existing **Next.js application** that contains layout/design issues and some configuration bugs. Your task is to identify and resolve these issues, and implement the listed features to enhance the overall user experience. - ---- - -## Tasks - -### 1. Fix Cards Layout & Responsiveness -- Correct the existing card grid layout. -- Improve the overall card design (UI/UX sensibility expected). -- Ensure the page is fully responsive across devices (desktop, tablet, mobile). - -### 2. Add Navbar (Sticky) -- Implement a navigation bar that remains fixed at the top while scrolling. -- Design should be clean and responsive. - -### 3. Optimize Page Load & Performance -- Implement optimizations such as: - - **Lazy loading** for images and non-critical components. - - **Memoization** to avoid unnecessary re-renders. - - **Skeleton loading screens** for better UX during data fetch. - -### 4. Implement Pagination -- Add pagination for the workers listing page. -- Each page should load a suitable number of items (e.g., 9–12 cards per page). - -### 5. Service Filters -- Implement filters for workers based on **price/day** and **type of service**. -- Filters should work seamlessly with pagination. - -### 6. Bug Fixes -- Identify and fix any existing issues in `page.tsx` or configuration files. -- Resolve console warnings or errors. -- Ensure clean and maintainable code following best practices. - -### 7. API Integration -- Currently, the workers’ data is being imported directly from `workers.json`. -- Your task is to **serve this data via /api/wprkers API route**. -- Update the frontend page to fetch this data using `fetch` (or any modern method such as `useEffect`, `useSWR`, or React Query). -- Donot delete the existing data loading logic, comment it out. -- Implement: - - **Loading state** (use skeleton screens). - - **Error handling** (show a friendly error message if API fails). - - **Basic caching or memoization** to prevent redundant calls. - ---- - -## Expectations -- Use **TypeScript** and **Tailwind CSS** consistently. -- Follow **component-driven development** principles. -- Write **clean, readable, and reusable code**. -- Optimize for **performance and accessibility**. -- Maintain **Git commit history** (no single "final commit"). - ---- - -## Deliverables -1. Fork the repo and work from a branch named: assignment/ (for example: assignment/adarsh-maurya). -2. Implement improvements and features that demonstrate your mastery of the job requirements (UI polish, responsiveness, Tailwind usage, tests, accessibility, performance). -3. Push your branch to GitHub, add a clear README, and (strongly recommended) deploy the app (Vercel/Netlify/GH Pages) -3. Fill in the Google Form with your details for submission. - ---- - -## Evaluation Criteria -- Code quality, readability, and structure. -- UI/UX improvements and responsiveness. -- Correctness of functionality (filters, pagination, sticky navbar, optimisations). -- Debugging and problem-solving approach. -- Git usage and commit practices. -- Handling of API calls, loading states, and error cases. - ---- - -## Notes -- You are free to use libraries like **SWR** or **React Query**, but keep the implementation clean. -- Focus on **real-world production quality code**, not just quick fixes. -- Add comment for any **bug fix or optimization.** -- Document any **extra improvements** you make in your submission. - -Good luck 🚀 From 7fdad21ce59e3edce11431b4bbdaa91edc116a07 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 22:44:16 +0530 Subject: [PATCH 12/25] Project Completed --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..da5df7f --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# WorkerHub — Frontend Developer Assignment + +[Live Demo → workershub.vercel.app](https://workershub.vercel.app/) + +A responsive worker directory built with **Next.js**, **TypeScript**, and **Tailwind CSS**. +Allows users to browse, filter, and paginate worker profiles, with loading skeletons, dark mode, and image fallbacks. + +--- + +## 🚀 Features + +- **Responsive layout**: 1 / 2 / 3 columns depending on screen size +- **Navbar**: sticky top header with mobile menu +- **Filters**: search by name or service, filter by service category and price range +- **Pagination**: navigate across multiple pages of worker cards +- **Skeleton Cards**: placeholders shown while data loads +- **Image handling**: fallback UI on image load failure, lazy loading +- **Client-side caching**: caches worker data to reduce redundant fetches +- **API route**: `/api/workers` serves `workers.json` + +--- + +## 🛠 Tech Stack + +- Next.js (App Router) +- TypeScript +- Tailwind CSS +- React + React Hooks +- Lucide Icons + +--- + + From a74777f1e0e9a51c9a8e4fa3570b52c1c106b549 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 22:47:14 +0530 Subject: [PATCH 13/25] ReadMe updated --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da5df7f..bfbb1f0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [Live Demo → workershub.vercel.app](https://workershub.vercel.app/) A responsive worker directory built with **Next.js**, **TypeScript**, and **Tailwind CSS**. -Allows users to browse, filter, and paginate worker profiles, with loading skeletons, dark mode, and image fallbacks. +Allows users to browse, filter, and paginate worker profiles, with loading skeletons, and image fallbacks. --- From 347c39d2ee38811dfab712d929f8c63504cf4296 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 22:53:10 +0530 Subject: [PATCH 14/25] title changed --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2cc2ba0..33b2491 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,7 +13,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "WorkerHub", + title: "WorkersHub", description: "Generated by create next app", }; From 0f37b675f500055bfe14d15358ef94a996804360 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 22:55:55 +0530 Subject: [PATCH 15/25] Title changed successfully --- src/app/components/Navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index 7da7d20..10afafe 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -32,7 +32,7 @@ export const Navbar = memo(() => { >
-

WorkerHub

+

WorkersHub

{/* Desktop Navigation */}
From b7641e2f8f0820073eec44d1ebe0c83e1ad630a5 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 23:12:27 +0530 Subject: [PATCH 16/25] small UI tweaks --- src/app/components/WorkerCard.tsx | 6 +- src/app/page.tsx | 708 +++++++++++++++--------------- 2 files changed, 357 insertions(+), 357 deletions(-) diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx index 3a4c154..d0d4e8a 100644 --- a/src/app/components/WorkerCard.tsx +++ b/src/app/components/WorkerCard.tsx @@ -52,7 +52,7 @@ export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => {

- + ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} / day @@ -61,8 +61,8 @@ export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { +18% GST
-
diff --git a/src/app/page.tsx b/src/app/page.tsx index da1cd6d..50265ae 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,397 +1,397 @@ -"use client"; + "use client"; -import React, { useState, useEffect, useMemo, useCallback } from "react"; -import { WorkerType } from "@/types/workers"; -import {Filters, Navbar, Pagination, SkeletonCard, WorkerCard} from './components/index' -import { FilterState } from "@/types/workers"; -import { ApiResponse } from "@/types/workers"; -import { Filter, Search, AlertCircle } from "lucide-react"; + import React, { useState, useEffect, useMemo, useCallback } from "react"; + import { WorkerType } from "@/types/workers"; + import {Filters, Navbar, Pagination, SkeletonCard, WorkerCard} from './components/index' + import { FilterState } from "@/types/workers"; + import { ApiResponse } from "@/types/workers"; + import { Filter, Search, AlertCircle } from "lucide-react"; -// MAIN WORKERS PAGE COMPONENT + // MAIN WORKERS PAGE COMPONENT -export default function WorkersPage() { + export default function WorkersPage() { - // LEGACY CODE (COMMENTED OUT) + // LEGACY CODE (COMMENTED OUT) - /* - const [workersData, setWorkersData] = useState([]) + /* + 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() // This was a duplicate call - bug fixed - }, []) - */ - - // NEW STATE MANAGEMENT - - const [workers, setWorkers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [showFilters, setShowFilters] = useState(false); - const [filters, setFilters] = useState({ - service: "", - minPrice: 0, - maxPrice: 1000, - searchQuery: "", - }); - - const itemsPerPage = 12; - - // ============================================================================ - // API INTEGRATION WITH CACHING - // ============================================================================ - - const fetchWorkers = useCallback(async () => { - try { - setLoading(true); - setError(null); - - // Check cache first - const cacheKey = "workers_data"; - const cacheTimestamp = "workers_timestamp"; - const cacheExpiry = 5 * 60 * 1000; // 5 minutes - - const cachedData = localStorage.getItem(cacheKey); - const cachedTime = localStorage.getItem(cacheTimestamp); - - if (cachedData && cachedTime) { - const isValid = Date.now() - parseInt(cachedTime) < cacheExpiry; - if (isValid) { - const parsedData = JSON.parse(cachedData); - setWorkers(parsedData); - setLoading(false); - return; + 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() // This was a duplicate call - bug fixed + }, []) + */ + + // NEW STATE MANAGEMENT + + const [workers, setWorkers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + service: "", + minPrice: 0, + maxPrice: 1000, + searchQuery: "", + }); - const response = await fetch("/api/workers", { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); + const itemsPerPage = 12; - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + // ============================================================================ + // API INTEGRATION WITH CACHING + // ============================================================================ - const result: ApiResponse = await response.json(); + const fetchWorkers = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // Check cache first + const cacheKey = "workers_data"; + const cacheTimestamp = "workers_timestamp"; + const cacheExpiry = 5 * 60 * 1000; // 5 minutes + + const cachedData = localStorage.getItem(cacheKey); + const cachedTime = localStorage.getItem(cacheTimestamp); + + if (cachedData && cachedTime) { + const isValid = Date.now() - parseInt(cachedTime) < cacheExpiry; + if (isValid) { + const parsedData = JSON.parse(cachedData); + setWorkers(parsedData); + setLoading(false); + return; + } + } - if (!result.success) { - throw new Error(result.error || "Failed to fetch workers"); - } + const response = await fetch("/api/workers", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); - const validWorkers = (result.data || []).filter( - (worker) => worker.id !== null && worker.pricePerDay > 0 - ); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } - setWorkers(validWorkers); + const result: ApiResponse = await response.json(); - // Cache the data - localStorage.setItem(cacheKey, JSON.stringify(validWorkers)); - localStorage.setItem(cacheTimestamp, Date.now().toString()); - } catch (err) { - console.error("Error fetching workers:", err); - setError( - err instanceof Error ? err.message : "An unknown error occurred" - ); + if (!result.success) { + throw new Error(result.error || "Failed to fetch workers"); + } - // Fallback to cached data if available - const cachedData = localStorage.getItem("workers_data"); - if (cachedData) { - try { - setWorkers(JSON.parse(cachedData)); - setError("Using cached data due to network error"); - } catch { - // Ignore parsing errors + const validWorkers = (result.data || []).filter( + (worker) => worker.id !== null && worker.pricePerDay > 0 + ); + + setWorkers(validWorkers); + + // Cache the data + localStorage.setItem(cacheKey, JSON.stringify(validWorkers)); + localStorage.setItem(cacheTimestamp, Date.now().toString()); + } catch (err) { + console.error("Error fetching workers:", err); + setError( + err instanceof Error ? err.message : "An unknown error occurred" + ); + + // Fallback to cached data if available + const cachedData = localStorage.getItem("workers_data"); + if (cachedData) { + try { + setWorkers(JSON.parse(cachedData)); + setError("Using cached data due to network error"); + } catch { + // Ignore parsing errors + } } + } finally { + setLoading(false); } - } finally { - setLoading(false); - } - }, []); + }, []); - useEffect(() => { - if (showFilters) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [showFilters]); - - useEffect(() => { - fetchWorkers(); - }, [fetchWorkers]); - - // ============================================================================ - // COMPUTED VALUES WITH MEMOIZATION - // ============================================================================ - - const { - filteredWorkers, - paginatedWorkers, - totalPages, - uniqueServices, - priceRange, - } = useMemo(() => { - // Get unique services - const services = Array.from( - new Set(workers.map((worker) => worker.service)) - ).sort(); - - // Calculate price range - const prices = workers.map((w) => Math.round(w.pricePerDay * 1.18)); - const range = { - min: Math.min(...prices) || 0, - max: Math.max(...prices) || 1000, - }; - - // Filter workers - let filtered = workers.filter((worker) => { - const matchesService = - !filters.service || - worker.service.toLowerCase() === filters.service.toLowerCase(); - const price = Math.round(worker.pricePerDay * 1.18); - const matchesPrice = - price >= filters.minPrice && price <= filters.maxPrice; - const matchesSearch = - !filters.searchQuery || - worker.name.toLowerCase().includes(filters.searchQuery.toLowerCase()) || - worker.service - .toLowerCase() - .includes(filters.searchQuery.toLowerCase()); - - return matchesService && matchesPrice && matchesSearch; - }); - - // Sort by name - filtered = filtered.sort((a, b) => a.name.localeCompare(b.name)); - - // Calculate pagination - const totalPages = Math.ceil(filtered.length / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const paginated = filtered.slice(startIndex, startIndex + itemsPerPage); - - return { - filteredWorkers: filtered, - paginatedWorkers: paginated, + useEffect(() => { + if (showFilters) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [showFilters]); + + useEffect(() => { + fetchWorkers(); + }, [fetchWorkers]); + + // ============================================================================ + // COMPUTED VALUES WITH MEMOIZATION + // ============================================================================ + + const { + filteredWorkers, + paginatedWorkers, totalPages, - uniqueServices: services, - priceRange: range, - }; - }, [workers, filters, currentPage, itemsPerPage]); - - // Update price range in filters when workers data changes - useEffect(() => { - if (workers.length > 0) { - setFilters((prev) => { - // If user has not changed defaults, set to API range - if (prev.minPrice === 0 && prev.maxPrice === 1000) { + uniqueServices, + priceRange, + } = useMemo(() => { + // Get unique services + const services = Array.from( + new Set(workers.map((worker) => worker.service)) + ).sort(); + + // Calculate price range + const prices = workers.map((w) => Math.round(w.pricePerDay * 1.18)); + const range = { + min: Math.min(...prices) || 0, + max: Math.max(...prices) || 1000, + }; + + // Filter workers + let filtered = workers.filter((worker) => { + const matchesService = + !filters.service || + worker.service.toLowerCase() === filters.service.toLowerCase(); + const price = Math.round(worker.pricePerDay * 1.18); + const matchesPrice = + price >= filters.minPrice && price <= filters.maxPrice; + const matchesSearch = + !filters.searchQuery || + worker.name.toLowerCase().includes(filters.searchQuery.toLowerCase()) || + worker.service + .toLowerCase() + .includes(filters.searchQuery.toLowerCase()); + + return matchesService && matchesPrice && matchesSearch; + }); + + // Sort by name + filtered = filtered.sort((a, b) => a.name.localeCompare(b.name)); + + // Calculate pagination + const totalPages = Math.ceil(filtered.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginated = filtered.slice(startIndex, startIndex + itemsPerPage); + + return { + filteredWorkers: filtered, + paginatedWorkers: paginated, + totalPages, + uniqueServices: services, + priceRange: range, + }; + }, [workers, filters, currentPage, itemsPerPage]); + + // Update price range in filters when workers data changes + useEffect(() => { + if (workers.length > 0) { + setFilters((prev) => { + // If user has not changed defaults, set to API range + if (prev.minPrice === 0 && prev.maxPrice === 1000) { + return { + ...prev, + minPrice: priceRange.min, + maxPrice: priceRange.max, + }; + } + // otherwise preserve user's values but clamp them within the new range return { ...prev, - minPrice: priceRange.min, - maxPrice: priceRange.max, + minPrice: Math.max(priceRange.min, prev.minPrice), + maxPrice: Math.min(priceRange.max, prev.maxPrice), }; - } - // otherwise preserve user's values but clamp them within the new range - return { - ...prev, - minPrice: Math.max(priceRange.min, prev.minPrice), - maxPrice: Math.min(priceRange.max, prev.maxPrice), - }; - }); + }); + } + }, [workers.length, priceRange.min, priceRange.max]); + + // Reset page when filters change + useEffect(() => { + setCurrentPage(1); + }, [filters]); + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + + const handleFiltersChange = useCallback((newFilters: FilterState) => { + setFilters(newFilters); + }, []); + + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + + // ============================================================================ + // ERROR STATE + // ============================================================================ + + if (error && workers.length === 0) { + return ( +
+ +
+
+ +

+ Unable to load workers data +

+

{error}

+ +
+
+
+ ); } - }, [workers.length, priceRange.min, priceRange.max]); - - // Reset page when filters change - useEffect(() => { - setCurrentPage(1); - }, [filters]); - - // ============================================================================ - // EVENT HANDLERS - // ============================================================================ - - const handleFiltersChange = useCallback((newFilters: FilterState) => { - setFilters(newFilters); - }, []); - - const handlePageChange = useCallback((page: number) => { - setCurrentPage(page); - window.scrollTo({ top: 0, behavior: "smooth" }); - }, []); - // ============================================================================ - // ERROR STATE - // ============================================================================ + // ============================================================================ + // MAIN RENDER + // ============================================================================ - if (error && workers.length === 0) { return (
-
-
- -

- Unable to load workers data -

-

{error}

- + +
+ {/* Header Section */} +
+

+ Find Skilled Workers +

+

+ Browse through our verified professionals and find the perfect + worker for your needs +

+ {error && workers.length > 0 && ( +
+ {error} +
+ )}
-
-
- ); - } - - // ============================================================================ - // MAIN RENDER - // ============================================================================ - - return ( -
- - -
- {/* Header Section */} -
-

- Find Skilled Workers -

-

- Browse through our verified professionals and find the perfect - worker for your needs -

- {error && workers.length > 0 && ( -
- {error} -
- )} -
-
- {/* Filters Sidebar */} -
- - -
+
+ {/* Filters Sidebar */} +
+ + +
+ setShowFilters(false)} + closeOnOutsideClick={false} + /> +
+ setShowFilters(false)} - closeOnOutsideClick={false} />
- setShowFilters(false)} - /> -
- - {/* Workers Grid */} -
- {/* Results Info */} - {!loading && ( -
-

- Showing {paginatedWorkers.length} of {filteredWorkers.length}{" "} - workers - {filters.service && ( - in {filters.service} - )} -

-
- Page {currentPage} of {totalPages} -
-
- )} - - {/* Loading State */} - {loading && ( -
- {Array.from({ length: 12 }).map((_, index) => ( - - ))} -
- )} - {/* Workers Grid */} - {!loading && paginatedWorkers.length > 0 && ( -
- {paginatedWorkers.map((worker) => ( - - ))} -
- )} - - {/* No Results */} - {!loading && filteredWorkers.length === 0 && ( -
- -

- No workers found -

-

- Try adjusting your filters or search terms -

- -
- )} - - {/* Pagination */} - {!loading && ( - - )} +
+ {/* Results Info */} + {!loading && ( +
+

+ Showing {paginatedWorkers.length} of {filteredWorkers.length}{" "} + workers + {filters.service && ( + in {filters.service} + )} +

+
+ Page {currentPage} of {totalPages} +
+
+ )} + + {/* Loading State */} + {loading && ( +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+ )} + + {/* Workers Grid */} + {!loading && paginatedWorkers.length > 0 && ( +
+ {paginatedWorkers.map((worker) => ( + + ))} +
+ )} + + {/* No Results */} + {!loading && filteredWorkers.length === 0 && ( +
+ +

+ No workers found +

+

+ Try adjusting your filters or search terms +

+ +
+ )} + + {/* Pagination */} + {!loading && ( + + )} +
-
-
-
- ); -} \ No newline at end of file + +
+ ); + } \ No newline at end of file From fa893b27584966ce7c69dc1828de01bf44e5aafd Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 23:18:32 +0530 Subject: [PATCH 17/25] Final commit --- src/app/components/WorkerCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx index d0d4e8a..8c941bc 100644 --- a/src/app/components/WorkerCard.tsx +++ b/src/app/components/WorkerCard.tsx @@ -61,7 +61,7 @@ export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { +18% GST
-
From 389da25fb8e7d68edc060c2994848cac4912292a Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Thu, 25 Sep 2025 23:30:06 +0530 Subject: [PATCH 18/25] typo corrected in ReadMe --- README.md | 4 ++-- src/app/page.tsx | 10 ---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bfbb1f0..b61df30 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# WorkerHub — Frontend Developer Assignment +# WorkersHub — Frontend Developer Assignment [Live Demo → workershub.vercel.app](https://workershub.vercel.app/) @@ -9,7 +9,7 @@ Allows users to browse, filter, and paginate worker profiles, with loading skele ## 🚀 Features -- **Responsive layout**: 1 / 2 / 3 columns depending on screen size +- **Responsive layout**: 2 / 2 / 3 columns depending on screen size - **Navbar**: sticky top header with mobile menu - **Filters**: search by name or service, filter by service category and price range - **Pagination**: navigate across multiple pages of worker cards diff --git a/src/app/page.tsx b/src/app/page.tsx index 50265ae..40b313a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -47,9 +47,7 @@ const itemsPerPage = 12; - // ============================================================================ // API INTEGRATION WITH CACHING - // ============================================================================ const fetchWorkers = useCallback(async () => { try { @@ -136,9 +134,7 @@ fetchWorkers(); }, [fetchWorkers]); - // ============================================================================ // COMPUTED VALUES WITH MEMOIZATION - // ============================================================================ const { filteredWorkers, @@ -221,9 +217,7 @@ setCurrentPage(1); }, [filters]); - // ============================================================================ // EVENT HANDLERS - // ============================================================================ const handleFiltersChange = useCallback((newFilters: FilterState) => { setFilters(newFilters); @@ -234,9 +228,7 @@ window.scrollTo({ top: 0, behavior: "smooth" }); }, []); - // ============================================================================ // ERROR STATE - // ============================================================================ if (error && workers.length === 0) { return ( @@ -261,9 +253,7 @@ ); } - // ============================================================================ // MAIN RENDER - // ============================================================================ return (
From b595d93e248d0d44cd80412a69d2c8d314145a93 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Fri, 26 Sep 2025 14:02:53 +0530 Subject: [PATCH 19/25] feat: add footer, navbar search, and worker detail modal --- README.md | 47 +- package-lock.json | 10 + package.json | 1 + src/app/components/Filters.tsx | 19 +- src/app/components/Footer.tsx | 108 +++++ src/app/components/Navbar.tsx | 239 ++++++---- src/app/components/WorkerCard.tsx | 122 ++--- src/app/components/WorkerModal.tsx | 119 +++++ src/app/layout.tsx | 3 + src/app/page.tsx | 684 +++++++++++++++-------------- src/types/workers.ts | 13 +- 11 files changed, 863 insertions(+), 502 deletions(-) create mode 100644 src/app/components/Footer.tsx create mode 100644 src/app/components/WorkerModal.tsx diff --git a/README.md b/README.md index b61df30..fa204f2 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,22 @@ [Live Demo → workershub.vercel.app](https://workershub.vercel.app/) A responsive worker directory built with **Next.js**, **TypeScript**, and **Tailwind CSS**. -Allows users to browse, filter, and paginate worker profiles, with loading skeletons, and image fallbacks. +Allows users to browse, filter, and paginate worker profiles, with loading skeletons, image fallbacks, and detailed worker info modals. --- ## 🚀 Features -- **Responsive layout**: 2 / 2 / 3 columns depending on screen size -- **Navbar**: sticky top header with mobile menu -- **Filters**: search by name or service, filter by service category and price range +- **Responsive layout**: 2 / 2 / 4 columns depending on screen size +- **Navbar**: sticky top header with dedicated search bar (search by name or service) + mobile menu +- **Filters**: filter by service category and price range - **Pagination**: navigate across multiple pages of worker cards - **Skeleton Cards**: placeholders shown while data loads - **Image handling**: fallback UI on image load failure, lazy loading - **Client-side caching**: caches worker data to reduce redundant fetches - **API route**: `/api/workers` serves `workers.json` +- **Worker Detail Modal**: tap a card to view full profile, rating, availability, and price +- **Footer**: clean company-style footer at the bottom --- @@ -27,7 +29,44 @@ Allows users to browse, filter, and paginate worker profiles, with loading skele - Tailwind CSS - React + React Hooks - Lucide Icons +- React Icons --- +## 📂 Project Structure +src/ +├─ app/ +│ ├─ api/workers/route.ts # API endpoint for workers.json +│ ├─ components/ # Reusable components +│ │ ├─ Navbar.tsx +│ │ ├─ Filters.tsx +│ │ ├─ WorkerCard.tsx +│ │ ├─ WorkerModal.tsx +│ │ ├─ Pagination.tsx +│ │ ├─ SkeletonCard.tsx +│ │ └─ Footer.tsx +│ └─ page.tsx # WorkersPage main screen +├─ types/ +│ └─ workers.ts # Worker & filter types +└─ workers.json # Mock worker dataset + + +--- + +## ⚡️ Getting Started + +```bash +# Install dependencies +npm install + +# Run development server +npm run dev + +# Build for production +npm run build +npm start + +📦 Deployment + +Deployed with Vercel → workershub.vercel.app \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 50e8901..11a5534 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-icons": "^5.5.0", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -5055,6 +5056,15 @@ "react": "^19.1.0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 14cd481..1f2cb19 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", + "react-icons": "^5.5.0", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/src/app/components/Filters.tsx b/src/app/components/Filters.tsx index 5517d82..beae539 100644 --- a/src/app/components/Filters.tsx +++ b/src/app/components/Filters.tsx @@ -4,7 +4,6 @@ import React, { useEffect, useCallback, memo } from "react"; import { - Search, X, } from "lucide-react"; @@ -72,21 +71,7 @@ export const Filters = memo(
{/* Search */}
- -
- - - handleFilterChange("searchQuery", e.target.value) - } - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" - /> -
+
{/* Service Filter */} @@ -197,7 +182,7 @@ export const Filters = memo( searchQuery: "", }) } - className="w-full py-2 px-4 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200" + className="w-full py-2 px-4 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200 cursor-pointer" > Clear All Filters diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx new file mode 100644 index 0000000..5b7f491 --- /dev/null +++ b/src/app/components/Footer.tsx @@ -0,0 +1,108 @@ +import { + FaFacebook, + FaTwitter, + FaInstagramSquare, + FaLinkedin, +} from "react-icons/fa"; +import { FaPeopleGroup } from "react-icons/fa6"; + +export default function Footer() { + return ( + + ); +} diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index 10afafe..d2fef6c 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -1,118 +1,173 @@ -// NAVBAR COMPONENT - -import React, { useState, useEffect, memo } from "react"; -import { - Menu, - Home, - Users, - Info, - Phone, -} from "lucide-react"; - -export const Navbar = memo(() => { - const [isScrolled, setIsScrolled] = useState(false); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - - useEffect(() => { - const handleScroll = () => { - setIsScrolled(window.scrollY > 10); +// NAVBAR + +"use client"; + +import React, { useEffect, useState, memo, useRef } from "react"; +import { Menu, Home, Users, Info, Phone, Search } from "lucide-react"; +import { FaPeopleGroup } from "react-icons/fa6"; + +type NavbarProps = { + searchQuery?: string; + onSearchChange?: (q: string) => void; +}; + +export const Navbar = memo( + ({ searchQuery = "", onSearchChange }: NavbarProps) => { + const [isScrolled, setIsScrolled] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const onScroll = () => setIsScrolled(window.scrollY > 10); + window.addEventListener("scroll", onScroll); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + // close mobile menu when clicking outside + useEffect(() => { + if (!isMobileMenuOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsMobileMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, [isMobileMenuOpen]); + + const [localQuery, setLocalQuery] = useState(searchQuery); + useEffect(() => setLocalQuery(searchQuery), [searchQuery]); + + const handleChange = (v: string) => { + setLocalQuery(v); + onSearchChange?.(v); }; - window.addEventListener("scroll", handleScroll); - return () => window.removeEventListener("scroll", handleScroll); - }, []); - - return ( - + ); + } +); +Navbar.displayName = "Navbar"; diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx index 8c941bc..1684e78 100644 --- a/src/app/components/WorkerCard.tsx +++ b/src/app/components/WorkerCard.tsx @@ -1,71 +1,83 @@ - -// WORKER CARD COMPONENT - import React, { useState, memo } from "react"; import Image from "next/image"; import { WorkerType } from "@/types/workers"; -import { - AlertCircle, - Loader2, - -} from "lucide-react"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { WorkerModal } from "./WorkerModal"; export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); return ( -
-
- {!imageLoaded && !imageError && ( -
- -
- )} - {imageError ? ( -
- - Image not available -
- ) : ( - {worker.name} setImageLoaded(true)} - onError={() => setImageError(true)} - loading="lazy" - sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" - /> - )} -
-
-

- {worker.name} -

-

- {worker.service} -

-
-
- - ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} + <> +
setIsModalOpen(true)} + > +
+ {!imageLoaded && !imageError && ( +
+ +
+ )} + {imageError ? ( +
+ + Image not available +
+ ) : ( + {worker.name} setImageLoaded(true)} + onError={() => setImageError(true)} + loading="lazy" + sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" + /> + )} +
+
+

+ {worker.name} +

+

+ {worker.service} +

+ +
+
+ + ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} + + / day +
+ + +18% GST - / day
- - +18% GST - +
-
-
+ + {/* Modal */} + {isModalOpen && ( + setIsModalOpen(false)} /> + )} + ); }); diff --git a/src/app/components/WorkerModal.tsx b/src/app/components/WorkerModal.tsx new file mode 100644 index 0000000..e4454cf --- /dev/null +++ b/src/app/components/WorkerModal.tsx @@ -0,0 +1,119 @@ +import React, { memo, useRef, useEffect } from "react"; +import Image from "next/image"; +import { WorkerType } from "@/types/workers"; +import { X, Star } from "lucide-react"; + +interface WorkerModalProps { + worker: WorkerType | null; + onClose: () => void; +} + +export const WorkerModal = memo(({ worker, onClose }: WorkerModalProps) => { + const modalRef = useRef(null); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + useEffect(() => { + // Lock scroll + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = ""; + }; + }, []); + + if (!worker) return null; + + return ( +
+
+ {/* Close Button */} + + + {/* Worker Image */} +
+ {worker.name} +
+ + {/* Worker Details */} +
+

{worker.name}

+

{worker.service}

+ + {/* Rating */} +
+ {Array.from({ length: 1 }).map((_, i) => ( + + ))} + + {worker.rating ? worker.rating.toFixed(1) : "No rating"} + +
+ + {/* Availability badge */} +
+ {worker.available ? ( + + Available in your area + + ) : ( + + Not available in your area + + )} +
+ + {/* Price */} +

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

+ + +18% GST + + + {/* CTA */} + +
+
+
+ ); +}); + +WorkerModal.displayName = "WorkerModal"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 33b2491..685cfe3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import Footer from "./components/Footer"; import "./globals.css"; const geistSans = Geist({ @@ -28,7 +29,9 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} +
); } + diff --git a/src/app/page.tsx b/src/app/page.tsx index 40b313a..419ef27 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,20 +1,24 @@ - "use client"; - - import React, { useState, useEffect, useMemo, useCallback } from "react"; - import { WorkerType } from "@/types/workers"; - import {Filters, Navbar, Pagination, SkeletonCard, WorkerCard} from './components/index' - import { FilterState } from "@/types/workers"; - import { ApiResponse } from "@/types/workers"; - import { Filter, Search, AlertCircle } from "lucide-react"; - - - // MAIN WORKERS PAGE COMPONENT - - export default function WorkersPage() { - - // LEGACY CODE (COMMENTED OUT) - - /* +"use client"; + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { WorkerType } from "@/types/workers"; +import { + Filters, + Navbar, + Pagination, + SkeletonCard, + WorkerCard, +} from "./components/index"; +import { FilterState } from "@/types/workers"; +import { ApiResponse } from "@/types/workers"; +import { Filter, Search, AlertCircle } from "lucide-react"; + +// MAIN WORKERS PAGE COMPONENT + +export default function WorkersPage() { + // LEGACY CODE (COMMENTED OUT) + + /* const [workersData, setWorkersData] = useState([]) useEffect(() => { @@ -31,357 +35,379 @@ }, []) */ - // NEW STATE MANAGEMENT - - const [workers, setWorkers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [showFilters, setShowFilters] = useState(false); - const [filters, setFilters] = useState({ - service: "", - minPrice: 0, - maxPrice: 1000, - searchQuery: "", - }); + // NEW STATE MANAGEMENT + + const [workers, setWorkers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + service: "", + minPrice: 0, + maxPrice: 1000, + searchQuery: "", + }); + + const itemsPerPage = 12; + + // API INTEGRATION WITH CACHING + + const fetchWorkers = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // Check cache first + const cacheKey = "workers_data"; + const cacheTimestamp = "workers_timestamp"; + const cacheExpiry = 5 * 60 * 1000; // 5 minutes + + const cachedData = localStorage.getItem(cacheKey); + const cachedTime = localStorage.getItem(cacheTimestamp); + + if (cachedData && cachedTime) { + const isValid = Date.now() - parseInt(cachedTime) < cacheExpiry; + if (isValid) { + const parsedData = JSON.parse(cachedData); + setWorkers(parsedData); + setLoading(false); + return; + } + } - const itemsPerPage = 12; + const response = await fetch("/api/workers", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); - // API INTEGRATION WITH CACHING + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } - const fetchWorkers = useCallback(async () => { - try { - setLoading(true); - setError(null); + const result: ApiResponse = await response.json(); - // Check cache first - const cacheKey = "workers_data"; - const cacheTimestamp = "workers_timestamp"; - const cacheExpiry = 5 * 60 * 1000; // 5 minutes + if (!result.success) { + throw new Error(result.error || "Failed to fetch workers"); + } - const cachedData = localStorage.getItem(cacheKey); - const cachedTime = localStorage.getItem(cacheTimestamp); + const validWorkers = (result.data || []) + .filter((worker) => worker.id !== null && worker.pricePerDay > 0) + .map((worker, index) => { + // random rating between 3.5 and 5 + const rating = (Math.random() * (5 - 3.5) + 3.5).toFixed(1); - if (cachedData && cachedTime) { - const isValid = Date.now() - parseInt(cachedTime) < cacheExpiry; - if (isValid) { - const parsedData = JSON.parse(cachedData); - setWorkers(parsedData); - setLoading(false); - return; - } - } + // 50-50 split: even index = available, odd index = not available + const available = index % 2 === 0; - const response = await fetch("/api/workers", { - method: "GET", - headers: { - "Content-Type": "application/json", - }, + return { + ...worker, + rating: parseFloat(rating), + available, + }; }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const result: ApiResponse = await response.json(); + setWorkers(validWorkers); - if (!result.success) { - throw new Error(result.error || "Failed to fetch workers"); - } + // Cache the data + localStorage.setItem(cacheKey, JSON.stringify(validWorkers)); + localStorage.setItem(cacheTimestamp, Date.now().toString()); + } catch (err) { + console.error("Error fetching workers:", err); + setError( + err instanceof Error ? err.message : "An unknown error occurred" + ); - const validWorkers = (result.data || []).filter( - (worker) => worker.id !== null && worker.pricePerDay > 0 - ); - - setWorkers(validWorkers); - - // Cache the data - localStorage.setItem(cacheKey, JSON.stringify(validWorkers)); - localStorage.setItem(cacheTimestamp, Date.now().toString()); - } catch (err) { - console.error("Error fetching workers:", err); - setError( - err instanceof Error ? err.message : "An unknown error occurred" - ); - - // Fallback to cached data if available - const cachedData = localStorage.getItem("workers_data"); - if (cachedData) { - try { - setWorkers(JSON.parse(cachedData)); - setError("Using cached data due to network error"); - } catch { - // Ignore parsing errors - } + // Fallback to cached data if available + const cachedData = localStorage.getItem("workers_data"); + if (cachedData) { + try { + setWorkers(JSON.parse(cachedData)); + setError("Using cached data due to network error"); + } catch { + // Ignore parsing errors } - } finally { - setLoading(false); } - }, []); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - if (showFilters) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [showFilters]); + useEffect(() => { + if (showFilters) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [showFilters]); + + useEffect(() => { + fetchWorkers(); + }, [fetchWorkers]); + + // COMPUTED VALUES WITH MEMOIZATION + + const { + filteredWorkers, + paginatedWorkers, + totalPages, + uniqueServices, + priceRange, + } = useMemo(() => { + // Get unique services + const services = Array.from( + new Set(workers.map((worker) => worker.service)) + ).sort(); + + // Calculate price range + const prices = workers.map((w) => Math.round(w.pricePerDay * 1.18)); + const range = { + min: Math.min(...prices) || 0, + max: Math.max(...prices) || 1000, + }; + + // Filter workers + let filtered = workers.filter((worker) => { + const matchesService = + !filters.service || + worker.service.toLowerCase() === filters.service.toLowerCase(); + const price = Math.round(worker.pricePerDay * 1.18); + const matchesPrice = + price >= filters.minPrice && price <= filters.maxPrice; + const matchesSearch = + !filters.searchQuery || + worker.name.toLowerCase().includes(filters.searchQuery.toLowerCase()) || + worker.service + .toLowerCase() + .includes(filters.searchQuery.toLowerCase()); + + return matchesService && matchesPrice && matchesSearch; + }); - useEffect(() => { - fetchWorkers(); - }, [fetchWorkers]); + // Sort by name + filtered = filtered.sort((a, b) => a.name.localeCompare(b.name)); - // COMPUTED VALUES WITH MEMOIZATION + // Calculate pagination + const totalPages = Math.ceil(filtered.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginated = filtered.slice(startIndex, startIndex + itemsPerPage); - const { - filteredWorkers, - paginatedWorkers, + return { + filteredWorkers: filtered, + paginatedWorkers: paginated, totalPages, - uniqueServices, - priceRange, - } = useMemo(() => { - // Get unique services - const services = Array.from( - new Set(workers.map((worker) => worker.service)) - ).sort(); - - // Calculate price range - const prices = workers.map((w) => Math.round(w.pricePerDay * 1.18)); - const range = { - min: Math.min(...prices) || 0, - max: Math.max(...prices) || 1000, - }; - - // Filter workers - let filtered = workers.filter((worker) => { - const matchesService = - !filters.service || - worker.service.toLowerCase() === filters.service.toLowerCase(); - const price = Math.round(worker.pricePerDay * 1.18); - const matchesPrice = - price >= filters.minPrice && price <= filters.maxPrice; - const matchesSearch = - !filters.searchQuery || - worker.name.toLowerCase().includes(filters.searchQuery.toLowerCase()) || - worker.service - .toLowerCase() - .includes(filters.searchQuery.toLowerCase()); - - return matchesService && matchesPrice && matchesSearch; + uniqueServices: services, + priceRange: range, + }; + }, [workers, filters, currentPage, itemsPerPage]); + + // Update price range in filters when workers data changes + useEffect(() => { + if (workers.length > 0) { + setFilters((prev) => { + // If user has not changed defaults, set to API range + if (prev.minPrice === 0 && prev.maxPrice === 1000) { + return { + ...prev, + minPrice: priceRange.min, + maxPrice: priceRange.max, + }; + } + // otherwise preserve user's values but clamp them within the new range + return { + ...prev, + minPrice: Math.max(priceRange.min, prev.minPrice), + maxPrice: Math.min(priceRange.max, prev.maxPrice), + }; }); + } + }, [workers.length, priceRange.min, priceRange.max]); - // Sort by name - filtered = filtered.sort((a, b) => a.name.localeCompare(b.name)); + // Reset page when filters change + useEffect(() => { + setCurrentPage(1); + }, [filters]); - // Calculate pagination - const totalPages = Math.ceil(filtered.length / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const paginated = filtered.slice(startIndex, startIndex + itemsPerPage); + // EVENT HANDLERS - return { - filteredWorkers: filtered, - paginatedWorkers: paginated, - totalPages, - uniqueServices: services, - priceRange: range, - }; - }, [workers, filters, currentPage, itemsPerPage]); + const handleFiltersChange = useCallback((newFilters: FilterState) => { + setFilters(newFilters); + }, []); - // Update price range in filters when workers data changes - useEffect(() => { - if (workers.length > 0) { - setFilters((prev) => { - // If user has not changed defaults, set to API range - if (prev.minPrice === 0 && prev.maxPrice === 1000) { - return { - ...prev, - minPrice: priceRange.min, - maxPrice: priceRange.max, - }; - } - // otherwise preserve user's values but clamp them within the new range - return { - ...prev, - minPrice: Math.max(priceRange.min, prev.minPrice), - maxPrice: Math.min(priceRange.max, prev.maxPrice), - }; - }); - } - }, [workers.length, priceRange.min, priceRange.max]); + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); - // Reset page when filters change - useEffect(() => { - setCurrentPage(1); - }, [filters]); - - // EVENT HANDLERS - - const handleFiltersChange = useCallback((newFilters: FilterState) => { - setFilters(newFilters); - }, []); - - const handlePageChange = useCallback((page: number) => { - setCurrentPage(page); - window.scrollTo({ top: 0, behavior: "smooth" }); - }, []); - - // ERROR STATE - - if (error && workers.length === 0) { - return ( -
- -
-
- -

- Unable to load workers data -

-

{error}

- -
-
-
- ); - } - - // MAIN RENDER + // ERROR STATE + if (error && workers.length === 0) { return (
- -
- {/* Header Section */} -
-

- Find Skilled Workers -

-

- Browse through our verified professionals and find the perfect - worker for your needs -

- {error && workers.length > 0 && ( -
- {error} -
- )} +
+
+ +

+ Unable to load workers data +

+

{error}

+
+
+
+ ); + } + + // MAIN RENDER + + return ( +
+ setFilters((f) => ({ ...f, searchQuery: q }))} + /> + +
+ {/* Header Section */} +
+

+ Find Skilled Workers +

+

+ Browse through our verified professionals and find the perfect + worker for your needs +

+ {error && workers.length > 0 && ( +
+ {error} +
+ )} +
+ +
+ {/* Filters Sidebar */} +
+ {/* Mobile button */} + + + {/* Desktop sidebar with gray border */} +
+ setShowFilters(false)} + closeOnOutsideClick={false} + /> +
-
- {/* Filters Sidebar */} -
- - -
+ {/* Mobile overlay with gray border */} + {showFilters && ( +
setShowFilters(false)} - closeOnOutsideClick={false} />
+ )} +
- setShowFilters(false)} - /> -
+ {/* Workers Grid */} +
+ {/* Results Info */} + {!loading && ( +
+

+ Showing {paginatedWorkers.length} of {filteredWorkers.length}{" "} + workers + {filters.service && ( + in {filters.service} + )} +

+
+ Page {currentPage} of {totalPages} +
+
+ )} + + {/* Loading State */} + {loading && ( +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+ )} {/* Workers Grid */} -
- {/* Results Info */} - {!loading && ( -
-

- Showing {paginatedWorkers.length} of {filteredWorkers.length}{" "} - workers - {filters.service && ( - in {filters.service} - )} -

-
- Page {currentPage} of {totalPages} -
-
- )} - - {/* Loading State */} - {loading && ( -
- {Array.from({ length: 12 }).map((_, index) => ( - - ))} -
- )} - - {/* Workers Grid */} - {!loading && paginatedWorkers.length > 0 && ( -
- {paginatedWorkers.map((worker) => ( - - ))} -
- )} - - {/* No Results */} - {!loading && filteredWorkers.length === 0 && ( -
- -

- No workers found -

-

- Try adjusting your filters or search terms -

- -
- )} - - {/* Pagination */} - {!loading && ( - - )} -
+ {!loading && paginatedWorkers.length > 0 && ( +
+ {paginatedWorkers.map((worker) => ( + + ))} +
+ )} + + {/* No Results */} + {!loading && filteredWorkers.length === 0 && ( +
+ +

+ No workers found +

+

+ Try adjusting your filters or search terms +

+ +
+ )} + + {/* Pagination */} + {!loading && ( + + )}
-
-
- ); - } \ No newline at end of file +
+ +
+ ); +} diff --git a/src/types/workers.ts b/src/types/workers.ts index 771244f..5c52f65 100644 --- a/src/types/workers.ts +++ b/src/types/workers.ts @@ -1,11 +1,14 @@ export interface WorkerType { - id: number - name: string - service: string - pricePerDay: number - image: string + id: number; + name: string; + service: string; + pricePerDay: number; + image: string; + rating?: number; + available?: boolean; } + export interface FilterState { service: string; minPrice: number; From 158b589ca53ea18213716666cf0b1f447e6e224b Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Fri, 26 Sep 2025 14:14:08 +0530 Subject: [PATCH 20/25] fix: replace internal with next/link to satisfy Next.js lint --- src/app/components/Footer.tsx | 25 ++++++++++--------- src/app/components/Navbar.tsx | 46 +++++++++++++++++------------------ 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx index 5b7f491..03e14e4 100644 --- a/src/app/components/Footer.tsx +++ b/src/app/components/Footer.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { FaFacebook, FaTwitter, @@ -28,24 +29,24 @@ export default function Footer() {

Quick Links

@@ -81,16 +82,16 @@ export default function Footer() {

Follow Us

diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index d2fef6c..c537eae 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -1,9 +1,9 @@ -// NAVBAR +// NAVBAR "use client"; - +import Link from "next/link"; import React, { useEffect, useState, memo, useRef } from "react"; -import { Menu, Home, Users, Info, Phone, Search } from "lucide-react"; +import { Menu, Home, Users, Info, Search } from "lucide-react"; import { FaPeopleGroup } from "react-icons/fa6"; type NavbarProps = { @@ -57,12 +57,12 @@ export const Navbar = memo(
{/* Brand */} - + WorkersHub - + {/* Search (Desktop) */}
@@ -84,27 +84,27 @@ export const Navbar = memo( {/* Desktop nav */} {/* Mobile toggle */} @@ -141,27 +141,27 @@ export const Navbar = memo(
)} From 7b1788b092d4a95d2edaa2ba27bacb847f571d6d Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Fri, 26 Sep 2025 15:32:42 +0530 Subject: [PATCH 21/25] smooth transition on navbar expansion in phone --- README.md | 21 ------ src/app/components/Footer.tsx | 6 +- src/app/components/Navbar.tsx | 118 ++++++++++++++++++++-------------- 3 files changed, 71 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index fa204f2..5e72405 100644 --- a/README.md +++ b/README.md @@ -31,27 +31,6 @@ Allows users to browse, filter, and paginate worker profiles, with loading skele - Lucide Icons - React Icons ---- - -## 📂 Project Structure - -src/ -├─ app/ -│ ├─ api/workers/route.ts # API endpoint for workers.json -│ ├─ components/ # Reusable components -│ │ ├─ Navbar.tsx -│ │ ├─ Filters.tsx -│ │ ├─ WorkerCard.tsx -│ │ ├─ WorkerModal.tsx -│ │ ├─ Pagination.tsx -│ │ ├─ SkeletonCard.tsx -│ │ └─ Footer.tsx -│ └─ page.tsx # WorkersPage main screen -├─ types/ -│ └─ workers.ts # Worker & filter types -└─ workers.json # Mock worker dataset - - --- ## ⚡️ Getting Started diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx index 03e14e4..f1b888b 100644 --- a/src/app/components/Footer.tsx +++ b/src/app/components/Footer.tsx @@ -34,17 +34,17 @@ export default function Footer() {
  • - + Workers
  • - + About
  • - + Contact
  • diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx index c537eae..5fb7c21 100644 --- a/src/app/components/Navbar.tsx +++ b/src/app/components/Navbar.tsx @@ -1,9 +1,9 @@ -// NAVBAR +// NAVBAR "use client"; import Link from "next/link"; import React, { useEffect, useState, memo, useRef } from "react"; -import { Menu, Home, Users, Info, Search } from "lucide-react"; +import { Menu, Home, Users, Info, Search, X } from "lucide-react"; import { FaPeopleGroup } from "react-icons/fa6"; type NavbarProps = { @@ -92,14 +92,14 @@ export const Navbar = memo( Home Workers @@ -109,62 +109,80 @@ export const Navbar = memo( {/* Mobile toggle */}
    {/* Mobile menu + search */} - {isMobileMenuOpen && ( -
    -
    - -
    - - handleChange(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-3xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" - /> +
    +
    +
    +
    + +
    + + handleChange(e.target.value)} + className="w-full pl-10 pr-4 py-2 + rounded-xl border border-white/40 + bg-white/30 text-gray-900 + placeholder-gray-500 + focus:ring-2 focus:ring-blue-500 outline-none" + /> +
    -
    - + +
    - )} +
    ); From 4803e08d193c191de18f0ed020ef5206ecd09ea5 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Fri, 26 Sep 2025 16:04:19 +0530 Subject: [PATCH 22/25] pagination ui changed for phone --- src/app/components/Pagination.tsx | 96 ++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 34 deletions(-) diff --git a/src/app/components/Pagination.tsx b/src/app/components/Pagination.tsx index f99bc1e..65557e5 100644 --- a/src/app/components/Pagination.tsx +++ b/src/app/components/Pagination.tsx @@ -1,5 +1,3 @@ -// PAGINATION COMPONENT - import React, { useCallback, memo } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; @@ -15,8 +13,8 @@ export const Pagination = memo( }) => { const getVisiblePages = useCallback(() => { const delta = 2; - const range = []; - const rangeWithDots = []; + const range: (number | string)[] = []; + const rangeWithDots: (number | string)[] = []; for ( let i = Math.max(2, currentPage - delta); @@ -46,49 +44,79 @@ export const Pagination = memo( if (totalPages <= 1) return null; return ( -
    - +
    + {/* Mobile: compact */} +
    + + + + Page {currentPage} of{" "} + {totalPages} + + + +
    - {getVisiblePages().map((page, index) => ( - - {page === "..." ? ( - ... + {/* Desktop / tablets: full numbered */} +
    + + + {getVisiblePages().map((page, idx) => + page === "..." ? ( + + … + ) : ( - )} - - ))} + ) + )} - + +
    ); } ); Pagination.displayName = "Pagination"; - From 1cf00cbba3fea9bcb268228abc2f52c0095c0436 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Fri, 26 Sep 2025 19:46:04 +0530 Subject: [PATCH 23/25] card arranged asthetically --- src/app/components/WorkerCard.tsx | 56 +++++++------ src/app/components/WorkerModal.tsx | 124 ++++++++++++++++++++++------- 2 files changed, 128 insertions(+), 52 deletions(-) diff --git a/src/app/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx index 1684e78..3e26162 100644 --- a/src/app/components/WorkerCard.tsx +++ b/src/app/components/WorkerCard.tsx @@ -12,19 +12,19 @@ export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { return ( <>
    setIsModalOpen(true)} > -
    +
    {!imageLoaded && !imageError && ( -
    - +
    +
    )} {imageError ? ( -
    - - Image not available +
    + + Image unavailable
    ) : ( { alt={worker.name} fill unoptimized - className={`object-cover group-hover:scale-105 transition-transform duration-300 ${ + className={`object-cover group-hover:scale-110 transition-all duration-500 ${ imageLoaded ? "opacity-100" : "opacity-0" }`} onLoad={() => setImageLoaded(true)} @@ -41,34 +41,40 @@ export const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" /> )} + + {/* Overlay gradient */} +
    -
    -

    - {worker.name} -

    -

    - {worker.service} -

    -
    -
    - +
    + {/* Header Section */} +
    +

    + {worker.name} +

    +

    {worker.service}

    +
    + + {/* Price Section */} +
    +
    + ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} - / day + / day
    - - +18% GST - +
    +18% GST included
    + + {/* Contact Button */}
    diff --git a/src/app/components/WorkerModal.tsx b/src/app/components/WorkerModal.tsx index e4454cf..2b68e03 100644 --- a/src/app/components/WorkerModal.tsx +++ b/src/app/components/WorkerModal.tsx @@ -34,79 +34,149 @@ export const WorkerModal = memo(({ worker, onClose }: WorkerModalProps) => { if (!worker) return null; return ( -
    +
    + + {/* Close Button */} {/* Worker Image */} -
    +
    {worker.name}
    {/* Worker Details */} -
    -

    {worker.name}

    -

    {worker.service}

    +
    + {/* Name and Service */} +
    +

    {worker.name}

    +

    {worker.service}

    +
    {/* Rating */} -
    - {Array.from({ length: 1 }).map((_, i) => ( +
    + {Array.from({ length: 5 }).map((_, i) => ( ))} - + {worker.rating ? worker.rating.toFixed(1) : "No rating"}
    - {/* Availability badge */} -
    + {/* Availability */} +
    {worker.available ? ( - + +
    Available in your area
    ) : ( - + +
    Not available in your area
    )}
    {/* Price */} -

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

    - - +18% GST - - - {/* CTA */} +
    +
    + ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} + / day +
    +
    +18% GST included
    +
    + + {/* Contact Button */} From 9a19b9f4c2b138098d2984d37397335eb3c24e57 Mon Sep 17 00:00:00 2001 From: AnuragPatel01 Date: Fri, 26 Sep 2025 19:51:07 +0530 Subject: [PATCH 24/25] changes applied --- src/app/components/WorkerModal.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/components/WorkerModal.tsx b/src/app/components/WorkerModal.tsx index 2b68e03..c4467ec 100644 --- a/src/app/components/WorkerModal.tsx +++ b/src/app/components/WorkerModal.tsx @@ -45,8 +45,6 @@ export const WorkerModal = memo(({ worker, onClose }: WorkerModalProps) => { className="bg-white rounded-xl shadow-xl max-w-xs w-full mx-4 relative overflow-hidden" style={{ animation: "modalSlideIn 0.3s ease-out", - "--tw-scale-x": "1", - "--tw-scale-y": "1", }} >