diff --git a/next.config.ts b/next.config.ts index 156ac95..900be79 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,9 +2,22 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ - images:{ - domains: ['images.unsplash.com','randomuser.me'], - } + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'images.unsplash.com', + port: '', + pathname: '/**', + }, + { + protocol: 'https', + hostname: 'randomuser.me', + port: '', + pathname: '/api/portraits/**', + }, + ], + }, }; export default nextConfig; diff --git a/src/app/api/workers/route.ts b/src/app/api/workers/route.ts index 44a245e..593b3d8 100644 --- a/src/app/api/workers/route.ts +++ b/src/app/api/workers/route.ts @@ -1,18 +1,94 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import workersData from '../../../../workers.json' +import { WorkerType } from '@/types/workers' -export async function GET() { +export async function GET(request: NextRequest) { try { - return NextResponse.json({ - success: true, - data: workersData - }) + const { searchParams } = new URL(request.url) + + // Extract query parameters + const page = parseInt(searchParams.get('page') || '1') + const limit = parseInt(searchParams.get('limit') || '12') + const services = searchParams.get('services')?.split(',').filter(Boolean) || [] + const minPrice = parseInt(searchParams.get('minPrice') || '0') + const maxPrice = parseInt(searchParams.get('maxPrice') || '999999') + const sortBy = searchParams.get('sortBy') || 'name' + + // Filter workers + const filteredWorkers: WorkerType[] = workersData + .filter((worker: WorkerType) => worker.pricePerDay > 0) + .filter((worker: WorkerType) => worker.id !== null) + .filter((worker: WorkerType) => { + // Service filter + if (services.length > 0 && !services.includes(worker.service)) { + return false + } + // Price filter (apply GST calculation here to match frontend) + const priceWithGST = Math.round(worker.pricePerDay * 1.18) + return priceWithGST >= minPrice && priceWithGST <= maxPrice + }) + + // Sort workers + switch (sortBy) { + case 'price-low': + filteredWorkers.sort((a, b) => a.pricePerDay - b.pricePerDay) + break + case 'price-high': + filteredWorkers.sort((a, b) => b.pricePerDay - a.pricePerDay) + break + case 'name': + default: + filteredWorkers.sort((a, b) => a.name.localeCompare(b.name)) + break + } + + // Pagination + const startIndex = (page - 1) * limit + const endIndex = startIndex + limit + const paginatedWorkers = filteredWorkers.slice(startIndex, endIndex) + + // Calculate total pages + const totalWorkers = filteredWorkers.length + const totalPages = Math.ceil(totalWorkers / limit) + + // Get unique services for filter options + const uniqueServices = Array.from( + new Set(workersData.map((worker: WorkerType) => worker.service)) + ).sort() + + // Calculate price range for filters + const allPrices = workersData + .filter((worker: WorkerType) => worker.pricePerDay > 0) + .map((worker: WorkerType) => Math.round(worker.pricePerDay * 1.18)) + const priceRange = { + min: Math.min(...allPrices), + max: Math.max(...allPrices) + } + + // Response with comprehensive data + const responseData = { + workers: paginatedWorkers, + pagination: { + currentPage: page, + totalPages, + totalWorkers, + limit, + hasNextPage: page < totalPages, + hasPrevPage: page > 1 + }, + filters: { + services: uniqueServices, + priceRange + } + } + + return NextResponse.json(responseData) } catch (error) { console.error('API Error:', error) - return NextResponse.json({ - success: false, - error: 'Failed to fetch workers data' - }, { status: 500 }) + return NextResponse.json( + { error: 'Failed to fetch workers data' }, + { status: 500 } + ) } } diff --git a/src/app/globals.css b/src/app/globals.css index d4b5078..fffab31 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1 +1,44 @@ @import 'tailwindcss'; + +/* Custom CSS for improved UI */ +@layer utilities { + .line-clamp-1 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + line-clamp: 1; + } + + .line-clamp-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + } +} + +/* Smooth scrolling for better UX */ +html { + scroll-behavior: smooth; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..61921c2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Navbar from "@/components/Navbar"; +import ErrorBoundary from "@/components/ErrorBoundary"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +15,16 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "WorkerHub - Find Skilled Workers", + description: "Connect with professional workers for all your service needs. Find the right worker for your project today.", + keywords: "workers, services, professionals, hire, skilled workers", + authors: [{ name: "WorkerHub Team" }], + robots: "index, follow", +}; + +export const viewport = { + width: 'device-width', + initialScale: 1, }; export default function RootLayout({ @@ -27,7 +37,10 @@ export default function RootLayout({ - {children} + + + {children} + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 23eaf49..0346250 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,57 +1,253 @@ 'use client' import { WorkerType } from '@/types/workers' import Image from 'next/image' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, useCallback } from 'react' +import { useWorkers } from '@/hooks/useWorkers' +import { WorkersGridSkeleton } from '@/components/WorkerCardSkeleton' +import Pagination from '@/components/Pagination' +import Filters from '@/components/Filters' +import { ErrorMessage } from '@/components/ErrorBoundary' + +interface FilterState { + services: string[] + minPrice: number + maxPrice: number + sortBy: 'name' | 'price-low' | 'price-high' +} export default function WorkersPage() { - const [workersData, setWorkersData] = useState([]) + // LEGACY DATA LOADING (COMMENTED OUT AS REQUESTED) + // 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() + // }, []) + + // STATE MANAGEMENT + const [currentPage, setCurrentPage] = useState(1) + const [filters, setFilters] = useState({ + services: [], + minPrice: 0, + maxPrice: 999999, + sortBy: 'name' + }) + + const itemsPerPage = 12 + // API INTEGRATION with custom hook + const { data, loading, error, refetch } = useWorkers({ + page: currentPage, + limit: itemsPerPage, + filters + }) + + // Initialize filter state with API data useEffect(() => { - const loadData = async () => { - try { - const response = await import('../../workers.json') - setWorkersData(response.default) - } catch (error) { - console.error('Failed to load workers:', error) - } + if (data?.filters && filters.minPrice === 0 && filters.maxPrice === 999999) { + setFilters(prev => ({ + ...prev, + minPrice: data.filters.priceRange.min, + maxPrice: data.filters.priceRange.max + })) } - loadData() - loadData() + }, [data?.filters, filters.minPrice, filters.maxPrice]) + + // Handle filter changes and reset to first page + const handleFiltersChange = useCallback((newFilters: FilterState) => { + setFilters(newFilters) + setCurrentPage(1) + }, []) + + // Handle page changes + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page) + // Smooth scroll to top of workers section + document.getElementById('workers')?.scrollIntoView({ behavior: 'smooth' }) }, []) return ( -
-

Our Workers

- -
- {workersData - .filter((worker) => worker.pricePerDay > 0) - .filter((worker) => worker.id !== null) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((worker: WorkerType) => ( -
-
- {worker.name} +
+ {/* Hero Section - Added pt-16 to account for fixed navbar */} +
+
+
+

+ Find Skilled Workers +

+

+ Connect with professional workers for all your service needs +

+ {/* Quick Stats */} + {data && ( +
+
+
+ {data.pagination.totalWorkers.toLocaleString()} +
+
Available Workers
+
+
+
+ {data.filters.services.length} +
+
Service Categories
+
-
-

{worker.name}

-

{worker.service}

-

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

+
+
+ + {/* Workers Grid Section */} +
+ {/* Filters */} + {data?.filters && ( + + )} + + {/* Results Summary */} + {data && !loading && ( +
+

+ Showing {data.workers.length} of {data.pagination.totalWorkers} workers + {filters.services.length > 0 && ( + + in {filters.services.join(', ')} + + )} +

+
+ Page {data.pagination.currentPage} of {data.pagination.totalPages} +
+
+ )} + + {/* Error State */} + {error && ( + + )} + + {/* Loading State */} + {loading && } + + {/* Workers Grid */} + {!loading && !error && data && ( + <> +
+ {data.workers.map((worker: WorkerType) => ( + + ))} +
+ + {/* No Results */} + {data.workers.length === 0 && ( +
+
+ + + +
+

+ No workers found +

+

+ Try adjusting your filters to see more results.

+
-
- ))} -
+ )} + + {/* Pagination */} + {data.pagination.totalPages > 1 && ( +
+ +
+ )} + + )} +
) } + +// IMPROVEMENT: Memoized worker card component for better performance +const WorkerCard = ({ worker }: { worker: WorkerType }) => { + return ( +
+ {/* Image Container with improved aspect ratio and lazy loading */} +
+ {`${worker.name} + {/* Service Badge */} +
+ + {worker.service} + +
+
+ + {/* Card Content */} +
+

+ {worker.name} +

+

+ {worker.service} +

+ + {/* Price Section */} +
+
+ + ₹{Math.round(worker.pricePerDay * 1.18)} + + /day +
+ +
+
+
+ ) +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..78196aa --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,99 @@ +'use client' +import { Component, ReactNode, ErrorInfo } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error?: Error +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback || ( +
+
+
+ + + +
+

+ Something went wrong +

+

+ We encountered an error while loading the page. Please try refreshing. +

+ +
+
+ ) + ) + } + + return this.props.children + } +} + +// Functional error boundary for API errors +interface ErrorMessageProps { + message: string + onRetry?: () => void +} + +export function ErrorMessage({ message, onRetry }: ErrorMessageProps) { + return ( +
+
+ + + +
+

+ Error Loading Data +

+

{message}

+ {onRetry && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/src/components/Filters.tsx b/src/components/Filters.tsx new file mode 100644 index 0000000..ffa2dbb --- /dev/null +++ b/src/components/Filters.tsx @@ -0,0 +1,176 @@ +'use client' +import { useState, useEffect } from 'react' + +interface FilterState { + services: string[] + minPrice: number + maxPrice: number + sortBy: 'name' | 'price-low' | 'price-high' +} + +interface FiltersProps { + filters: FilterState + onFiltersChange: (filters: FilterState) => void + availableServices: string[] + priceRange: { min: number; max: number } +} + +export default function Filters({ + filters, + onFiltersChange, + availableServices, + priceRange +}: FiltersProps) { + const [isOpen, setIsOpen] = useState(false) + const [localFilters, setLocalFilters] = useState(filters) + + // Update local filters when parent filters change + useEffect(() => { + setLocalFilters(filters) + }, [filters]) + + const handleServiceToggle = (service: string) => { + const updatedServices = localFilters.services.includes(service) + ? localFilters.services.filter(s => s !== service) + : [...localFilters.services, service] + + const updatedFilters = { ...localFilters, services: updatedServices } + setLocalFilters(updatedFilters) + onFiltersChange(updatedFilters) + } + + const handlePriceChange = (field: 'minPrice' | 'maxPrice', value: number) => { + const updatedFilters = { ...localFilters, [field]: value } + setLocalFilters(updatedFilters) + onFiltersChange(updatedFilters) + } + + const handleSortChange = (sortBy: FilterState['sortBy']) => { + const updatedFilters = { ...localFilters, sortBy } + setLocalFilters(updatedFilters) + onFiltersChange(updatedFilters) + } + + const clearFilters = () => { + const clearedFilters = { + services: [], + minPrice: priceRange.min, + maxPrice: priceRange.max, + sortBy: 'name' as const + } + setLocalFilters(clearedFilters) + onFiltersChange(clearedFilters) + } + + const activeFiltersCount = localFilters.services.length + + (localFilters.minPrice !== priceRange.min ? 1 : 0) + + (localFilters.maxPrice !== priceRange.max ? 1 : 0) + + (localFilters.sortBy !== 'name' ? 1 : 0) + + return ( +
+ {/* Mobile Toggle Button */} + + + {/* Filters Content */} +
+
+ {/* Service Types */} +
+

Services

+
+ {availableServices.map((service) => ( + + ))} +
+
+ + {/* Price Range */} +
+

Price Range (₹/day)

+
+
+ + handlePriceChange('minPrice', parseInt(e.target.value) || priceRange.min)} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + handlePriceChange('maxPrice', parseInt(e.target.value) || priceRange.max)} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+ + {/* Sort By */} +
+

Sort By

+ +
+ + {/* Clear Filters */} +
+ +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..e43db6f --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,162 @@ +'use client' +import { useState, useEffect } from 'react' + +export default function Navbar() { + const [isScrolled, setIsScrolled] = useState(false) + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) + + // Handle scroll effect for navbar background + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 0) + } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..d177b3e --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,132 @@ +interface PaginationProps { + currentPage: number + totalPages: number + onPageChange: (page: number) => void + className?: string +} + +export default function Pagination({ + currentPage, + totalPages, + onPageChange, + className = '' +}: PaginationProps) { + const getVisiblePages = () => { + const delta = 2 // Number of pages to show on each side of current page + 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 { + rangeWithDots.push(totalPages) + } + + return rangeWithDots + } + + if (totalPages <= 1) return null + + const visiblePages = getVisiblePages() + + return ( +
+ {/* Previous Button */} + + + {/* Page Numbers */} +
+ {visiblePages.map((page, index) => { + if (page === '...') { + return ( + + ... + + ) + } + + const pageNumber = page as number + const isCurrentPage = pageNumber === currentPage + + return ( + + ) + })} +
+ + {/* Next Button */} + +
+ ) +} \ No newline at end of file diff --git a/src/components/WorkerCardSkeleton.tsx b/src/components/WorkerCardSkeleton.tsx new file mode 100644 index 0000000..f06751b --- /dev/null +++ b/src/components/WorkerCardSkeleton.tsx @@ -0,0 +1,35 @@ +export default function WorkerCardSkeleton() { + return ( +
+ {/* Image Skeleton */} +
+ + {/* Content Skeleton */} +
+ {/* Name Skeleton */} +
+ + {/* Service Skeleton */} +
+ + {/* Price and Button Skeleton */} +
+
+
+
+
+
+
+
+ ) +} + +export function WorkersGridSkeleton({ count = 12 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+ ) +} \ No newline at end of file diff --git a/src/hooks/useWorkers.ts b/src/hooks/useWorkers.ts new file mode 100644 index 0000000..4736fd9 --- /dev/null +++ b/src/hooks/useWorkers.ts @@ -0,0 +1,97 @@ +'use client' +import { useState, useEffect, useCallback } from 'react' +import { WorkerType } from '@/types/workers' + +interface FilterState { + services: string[] + minPrice: number + maxPrice: number + sortBy: 'name' | 'price-low' | 'price-high' +} + +interface WorkersResponse { + workers: WorkerType[] + pagination: { + currentPage: number + totalPages: number + totalWorkers: number + limit: number + hasNextPage: boolean + hasPrevPage: boolean + } + filters: { + services: string[] + priceRange: { min: number; max: number } + } +} + +interface UseWorkersOptions { + page: number + limit: number + filters: FilterState +} + +interface UseWorkersReturn { + data: WorkersResponse | null + loading: boolean + error: string | null + refetch: () => void +} + +export function useWorkers({ page, limit, filters }: UseWorkersOptions): UseWorkersReturn { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchWorkers = useCallback(async () => { + try { + setLoading(true) + setError(null) + + // Build query parameters + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + sortBy: filters.sortBy + }) + + // Add service filters + if (filters.services.length > 0) { + params.append('services', filters.services.join(',')) + } + + // Add price filters + params.append('minPrice', filters.minPrice.toString()) + params.append('maxPrice', filters.maxPrice.toString()) + + const response = await fetch(`/api/workers?${params.toString()}`) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result = await response.json() + + if (result.error) { + throw new Error(result.error) + } + + setData(result) + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred while fetching workers') + console.error('Error fetching workers:', err) + } finally { + setLoading(false) + } + }, [page, limit, filters]) + + useEffect(() => { + fetchWorkers() + }, [fetchWorkers]) + + const refetch = useCallback(() => { + fetchWorkers() + }, [fetchWorkers]) + + return { data, loading, error, refetch } +} \ No newline at end of file