diff --git a/README.md b/README.md index 4389483..5e72405 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,51 @@ -# Frontend Developer Intern Assignment +# WorkersHub — Frontend Developer Assignment -## Mandatory Tasks -- Follow SolveEase on [Github](https://github.com/solve-ease) and [Linkedin](https://www.linkedin.com/company/solve-ease) -- Star this repo +[Live Demo → workershub.vercel.app](https://workershub.vercel.app/) -## 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. +A responsive worker directory built with **Next.js**, **TypeScript**, and **Tailwind CSS**. +Allows users to browse, filter, and paginate worker profiles, with loading skeletons, image fallbacks, and detailed worker info modals. --- -## Tasks +## 🚀 Features -### 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. +- **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 --- -## 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"). +## 🛠 Tech Stack + +- Next.js (App Router) +- TypeScript +- Tailwind CSS +- React + React Hooks +- Lucide Icons +- React Icons --- -## 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. +## ⚡️ Getting Started ---- +```bash +# Install dependencies +npm install -## 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. +# Run development server +npm run dev ---- +# Build for production +npm run build +npm start -## 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. +📦 Deployment -Good luck 🚀 +Deployed with Vercel → workershub.vercel.app \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3fdb753..11a5534 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "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", + "react-icons": "^5.5.0", + "tailwind-merge": "^3.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -2307,6 +2311,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 +4466,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", @@ -5034,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", @@ -5641,6 +5672,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..1f2cb19 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,23 @@ "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" + "react-icons": "^5.5.0", + "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" } } diff --git a/src/app/components/Filters.tsx b/src/app/components/Filters.tsx new file mode 100644 index 0000000..00ab2c2 --- /dev/null +++ b/src/app/components/Filters.tsx @@ -0,0 +1,204 @@ +// FILTERS COMPONENT + +import React, { useEffect, useCallback, memo } from "react"; + +import { Search, X } from "lucide-react"; + +import { FilterState } from "@/types/workers"; + +export 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 ( +
+ {/* ===== Mobile backdrop (blur + dim) ===== */} + + ); + } +); +Filters.displayName = "Filters"; diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx new file mode 100644 index 0000000..6e17e76 --- /dev/null +++ b/src/app/components/Footer.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import { + FaFacebook, + FaTwitter, + FaLinkedin, +} from "react-icons/fa"; +import { FaInstagram } from "react-icons/fa6"; +import { FaPeopleGroup } from "react-icons/fa6"; + +export default function Footer() { + return ( +
+
+ {/* Company Info */} +
+
+ +

WorkersHub

+
+ +

+ Connecting you with skilled professionals for all your work needs. + Trusted, reliable, and affordable. +

+
+ + {/* Quick Links */} +
+

Quick Links

+
    +
  • + + Home + +
  • +
  • + + Workers + +
  • +
  • + + About + +
  • +
  • + + Contact + +
  • +
+
+ + {/* Services */} +
+

Services

+ +
+ + {/* Socials */} +
+

Follow Us

+ +
+
+ + {/* Bottom Bar */} +
+

+ © {new Date().getFullYear()} WorkersHub. All rights reserved. +

+
+
+ ); +} diff --git a/src/app/components/Navbar.tsx b/src/app/components/Navbar.tsx new file mode 100644 index 0000000..5fb7c21 --- /dev/null +++ b/src/app/components/Navbar.tsx @@ -0,0 +1,191 @@ +// NAVBAR + +"use client"; +import Link from "next/link"; +import React, { useEffect, useState, memo, useRef } from "react"; +import { Menu, Home, Users, Info, Search, X } 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); + }; + + return ( + + ); + } +); +Navbar.displayName = "Navbar"; diff --git a/src/app/components/Pagination.tsx b/src/app/components/Pagination.tsx new file mode 100644 index 0000000..65557e5 --- /dev/null +++ b/src/app/components/Pagination.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, memo } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +export const Pagination = memo( + ({ + currentPage, + totalPages, + onPageChange, + }: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + }) => { + const getVisiblePages = useCallback(() => { + const delta = 2; + const range: (number | string)[] = []; + const rangeWithDots: (number | string)[] = []; + + 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 ( +
+ {/* Mobile: compact */} +
+ + + + Page {currentPage} of{" "} + {totalPages} + + + +
+ + {/* Desktop / tablets: full numbered */} +
+ + + {getVisiblePages().map((page, idx) => + page === "..." ? ( + + … + + ) : ( + + ) + )} + + +
+
+ ); + } +); + +Pagination.displayName = "Pagination"; diff --git a/src/app/components/SkeletonCard.tsx b/src/app/components/SkeletonCard.tsx new file mode 100644 index 0000000..aec5643 --- /dev/null +++ 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/components/WorkerCard.tsx b/src/app/components/WorkerCard.tsx new file mode 100644 index 0000000..3e26162 --- /dev/null +++ b/src/app/components/WorkerCard.tsx @@ -0,0 +1,90 @@ +import React, { useState, memo } from "react"; +import Image from "next/image"; +import { WorkerType } from "@/types/workers"; +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 ( + <> +
setIsModalOpen(true)} + > +
+ {!imageLoaded && !imageError && ( +
+ +
+ )} + {imageError ? ( +
+ + Image unavailable +
+ ) : ( + {worker.name} setImageLoaded(true)} + onError={() => setImageError(true)} + loading="lazy" + sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" + /> + )} + + {/* Overlay gradient */} +
+
+ +
+ {/* Header Section */} +
+

+ {worker.name} +

+

{worker.service}

+
+ + {/* Price Section */} +
+
+ + ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} + + / day +
+
+18% GST included
+
+ + {/* Contact Button */} + +
+
+ + {/* Modal */} + {isModalOpen && ( + setIsModalOpen(false)} /> + )} + + ); +}); + +WorkerCard.displayName = "WorkerCard"; diff --git a/src/app/components/WorkerModal.tsx b/src/app/components/WorkerModal.tsx new file mode 100644 index 0000000..c4467ec --- /dev/null +++ b/src/app/components/WorkerModal.tsx @@ -0,0 +1,187 @@ +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 */} +
+ {/* Name and Service */} +
+

{worker.name}

+

{worker.service}

+
+ + {/* Rating */} +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + {worker.rating ? worker.rating.toFixed(1) : "No rating"} + +
+ + {/* Availability */} +
+ {worker.available ? ( + +
+ Available in your area +
+ ) : ( + +
+ Not available in your area +
+ )} +
+ + {/* Price */} +
+
+ ₹{Math.round(worker.pricePerDay * 1.18).toLocaleString()} + / day +
+
+18% GST included
+
+ + {/* Contact Button */} + +
+
+
+ ); +}); + +WorkerModal.displayName = "WorkerModal"; 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/app/layout.tsx b/src/app/layout.tsx index f7fa87e..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({ @@ -13,7 +14,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", + title: "WorkersHub", description: "Generated by create next app", }; @@ -28,7 +29,9 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} +
+ ); } diff --git a/src/types/workers.ts b/src/types/workers.ts index 7cd826b..5c52f65 100644 --- a/src/types/workers.ts +++ b/src/types/workers.ts @@ -1,7 +1,24 @@ export interface WorkerType { - id: number - name: string - service: string - pricePerDay: number - image: string -} \ No newline at end of file + id: number; + name: string; + service: string; + pricePerDay: number; + image: string; + rating?: number; + available?: boolean; +} + + +export interface FilterState { + service: string; + minPrice: number; + maxPrice: number; + searchQuery: string; +} + + +export interface ApiResponse { + success: boolean; + data?: WorkerType[]; + error?: string; +}