diff --git a/README.md b/README.md index 4389483..e49468e 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,119 @@ -# 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"). +# SolveEase Frontend Assignment – Workers Directory + +Enterprise-quality Next.js app that lists workers with responsive cards, sticky navbar, API-driven data, pagination, filters, and performance/a11y optimizations. + +## Stack +- Next.js 15 (App Router) +- React 19 +- TypeScript +- Tailwind CSS v4 +- ESLint 9 + +## Getting started +1. Install dependencies: + ```bash + npm install + ``` +2. Run the app: + ```bash + npm run dev + ``` +3. Open `http://localhost:3000` + +## Features +- Sticky, responsive navbar and clean layout shell +- Workers listing with responsive grid (1/2/3/4 columns) +- API integration with loading skeletons and error handling +- Client-side pagination (12 items/page) +- Filters: by service and by max price/day (integrated with pagination) +- Performance improvements (memoization, lazy images, correct `sizes`, preconnect hints) +- Accessibility improvements (focus-visible styles, aria-live status, semantic controls) + +## Project structure (high level) +- `package.json` +- `tsconfig.json` +- `next.config.ts` +- `postcss.config.mjs` +- `workers.json` – mock data source +- `public/` – static assets +- `src/` + - `types/` + - `workers.ts` – `WorkerType` + - `app/` – Next.js App Router + - `globals.css` – Tailwind v4 import + - `layout.tsx` – layout shell, navbar, footer + - `page.tsx` – workers listing UI + - `api/` + - `workers/route.ts` – GET `/api/workers` + - `services/route.ts` – GET `/api/services` (unique services + stats) + +## API +### GET `/api/workers` +Returns `{ success: boolean, data: WorkerType[] }`. + +### GET `/api/services?stats=true|false` +- `?stats=true` returns service list with count/price stats +- `?stats=false` (default) returns unique service names + +## How it works +- The UI fetches from `/api/workers` on mount with an abort-safe `fetch`. +- While loading, skeleton cards are shown; on error, a friendly message is displayed. +- Users can filter by service or max price/day; results integrate with pagination. +- Cards are responsive and performance friendly (lazy images, proper `sizes`). + +## Changelog (what changed, why, impact) + +### src/app/page.tsx +- Switched from dynamic `import('../../workers.json')` to `fetch('/api/workers')` with AbortController. + - Why: Align with assignment; proper data flow and UX. + - Impact: Real API integration, skeletons for loading, clear error state, no duplicate requests. + +- Removed duplicate data loading and guarded with `hasFetchedRef`. + - Why: Bug fix; previous code called loader twice. + - Impact: Fewer renders, no redundant network calls. + +- Added filters (service dropdown and max price input) integrated with pagination. + - Why: Assignment requirement; better data exploration. + - Impact: Users refine results without losing pagination state. + +- Implemented pagination (12 per page) with accessible controls. + - Why: Assignment requirement; improve UX and perceived performance. + - Impact: Faster initial render, simple navigation between pages. + +- Extracted `WorkerCard` component and memoized with `React.memo`. + - Why: Cleaner structure; reduce unnecessary re-renders. + - Impact: More maintainable and performant list rendering. + +- Redesigned grid and cards; fixed `md:grid-cols-1` issue. + - Why: The grid collapsed at medium breakpoints; enterprise look & responsiveness. + - Impact: Consistent 1/2/3/4 column layout; polished UI. + +- Performance and a11y + - Added skeleton loaders, `useMemo` for transforms, lazy images with `sizes`. + - Added aria-live for result counts; added focus-visible rings; linked pagination buttons via `aria-controls`. + - Impact: Better LCP/CLS, keyboard accessibility, and assistive tech support. + +### src/app/layout.tsx +- Added sticky navbar and footer with a clean shell. + - Why: Enterprise-style frame and quick navigation; assignment requirement. + - Impact: Consistent navigation experience across the app. + +- Added preconnect hints to image domains. + - Why: Reduce latency for third-party images. + - Impact: Faster image loads; improved performance. + +## Development scripts +- `npm run dev` – Start dev server (Turbopack) +- `npm run build` – Production build +- `npm run start` – Start production server +- `npm run lint` – Lint codebase + +## Future enhancements +- Unit tests for filtering/pagination logic +- SWR/React Query for caching and revalidation +- Server-side pagination for very large datasets +- Lighthouse/perf budget and accessibility audits --- -## 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 🚀 +© SolveEase – Built with Next.js, React, and Tailwind CSS. diff --git a/next.config.ts b/next.config.ts index 156ac95..8f2bbbf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,8 +2,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ - images:{ - domains: ['images.unsplash.com','randomuser.me'], + images: { + domains: ['images.unsplash.com', 'randomuser.me'], + remotePatterns: [ + { protocol: 'https', hostname: 'images.unsplash.com' }, + { protocol: 'https', hostname: 'randomuser.me' }, + ], + // Some remote hosts intermittently block the Image Optimizer on Vercel. + // Disable optimization to avoid 502 from /_next/image for those hosts. + unoptimized: true, } }; diff --git a/src/app/api/services/route.ts b/src/app/api/services/route.ts index 1a660a9..75b5f79 100644 --- a/src/app/api/services/route.ts +++ b/src/app/api/services/route.ts @@ -1,9 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' -import workersData from '../../../../workers.json' + +export const runtime = 'nodejs' // GET /api/services export async function GET(request: NextRequest) { try { + const mod = await import('../../../../workers.json') + const workersData = mod.default // Simulate API delay await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 100)) @@ -37,36 +40,42 @@ export async function GET(request: NextRequest) { const includeStats = searchParams.get('stats') === 'true' if (includeStats) { - return NextResponse.json({ - success: true, - data: serviceStats, - metadata: { - totalServices: services.length, - totalWorkers: workersData.length + return NextResponse.json( + { + success: true, + data: serviceStats, + metadata: { + totalServices: services.length, + totalWorkers: workersData.length + }, + timestamp: new Date().toISOString() }, - timestamp: new Date().toISOString() - }, { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=300', + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=300' + } } - }) + ) } else { - return NextResponse.json({ - success: true, - data: services, - metadata: { - count: services.length + return NextResponse.json( + { + success: true, + data: services, + metadata: { + count: services.length + }, + timestamp: new Date().toISOString() }, - timestamp: new Date().toISOString() - }, { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=300', + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=300' + } } - }) + ) } } catch (error) { diff --git a/src/app/api/workers/route.ts b/src/app/api/workers/route.ts index 44a245e..d30c37a 100644 --- a/src/app/api/workers/route.ts +++ b/src/app/api/workers/route.ts @@ -1,12 +1,24 @@ import { NextResponse } from 'next/server' -import workersData from '../../../../workers.json' + +export const runtime = 'nodejs' export async function GET() { try { - return NextResponse.json({ - success: true, - data: workersData - }) + const mod = await import('../../../../workers.json') + const workersData = mod.default + return NextResponse.json( + { + success: true, + data: workersData + }, + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=300' + } + } + ) } catch (error) { console.error('API Error:', error) return NextResponse.json({ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..35f6779 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Link from "next/link"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -24,10 +25,32 @@ export default function RootLayout({ }>) { return ( + + + + - {children} +
+ +
+
+ {children} +
+ ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 23eaf49..f66391a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,57 +1,204 @@ 'use client' import { WorkerType } from '@/types/workers' import Image from 'next/image' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, useRef, memo } from 'react' export default function WorkersPage() { const [workersData, setWorkersData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [errorMessage, setErrorMessage] = useState('') + const hasFetchedRef = useRef(false) useEffect(() => { - const loadData = async () => { + // Old local import logic retained per assignment (commented out): + // const loadFromLocalJson = async () => { + // const response = await import('../../workers.json') + // setWorkersData(response.default) + // } + + const abortController = new AbortController() + + const fetchWorkers = async () => { + if (hasFetchedRef.current) return + hasFetchedRef.current = true + setIsLoading(true) + setErrorMessage('') try { - const response = await import('../../workers.json') - setWorkersData(response.default) - } catch (error) { - console.error('Failed to load workers:', error) + const res = await fetch('/api/workers', { + method: 'GET', + signal: abortController.signal, + headers: { + 'Accept': 'application/json' + }, + cache: 'force-cache' + }) + if (!res.ok) { + throw new Error(`Request failed: ${res.status}`) + } + const json = await res.json() + setWorkersData(json.data as WorkerType[]) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('Failed to fetch workers:', err) + setErrorMessage(message) + } finally { + setIsLoading(false) } } - loadData() - loadData() + + fetchWorkers() + return () => abortController.abort() }, []) + // Filters + const [selectedService, setSelectedService] = useState('all') + const [maxPrice, setMaxPrice] = useState(null) + + const filteredWorkers = useMemo(() => { + const byService = selectedService === 'all' + ? workersData + : workersData.filter(w => w.service === selectedService) + const byPrice = maxPrice != null + ? byService.filter(w => w.pricePerDay <= maxPrice) + : byService + return byPrice + }, [workersData, selectedService, maxPrice]) + + const sortedWorkers = useMemo(() => { + return filteredWorkers + .filter((worker) => worker.pricePerDay > 0) + .filter((worker) => worker.id !== null) + .sort((a, b) => a.name.localeCompare(b.name)) + }, [filteredWorkers]) + + // Pagination + const PAGE_SIZE = 12 + const [pageIndex, setPageIndex] = useState(0) + const totalPages = Math.ceil(sortedWorkers.length / PAGE_SIZE) + const pagedWorkers = useMemo(() => { + const start = pageIndex * PAGE_SIZE + return sortedWorkers.slice(start, start + PAGE_SIZE) + }, [sortedWorkers, pageIndex]) + + const WorkerCard = memo(({ worker }: { worker: WorkerType }) => { + return ( +
+
+ {worker.name} +
+
+
+
+

{worker.name}

+

{worker.service}

+
+
+

price/day

+

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

+
+
+
+
+ ) + }) + WorkerCard.displayName = 'WorkerCard' + 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) => ( -
+ Failed to load workers. {errorMessage} +
+ )} + + {/* Controls */} +
+
+
+ + +
+
+ + { const v = e.target.value; setMaxPrice(v === '' ? null : Number(v)); setPageIndex(0); }} + disabled={isLoading} + /> +
+
+
+ Showing {Math.min(sortedWorkers.length, (pageIndex * PAGE_SIZE) + (pagedWorkers.length))} of {filteredWorkers.length} filtered +
+
+ +
+ {isLoading + ? Array.from({ length: 8 }).map((_, idx) => ( +
+
+
+
+
+
+
-
- ))} + )) + : pagedWorkers.map((worker: WorkerType) => ( + + ))}
+ + {!isLoading && !errorMessage && ( +
+ +
Page {pageIndex + 1} of {Math.max(1, totalPages)}
+ +
+ )}
) }