243 lines
11 KiB
TypeScript
243 lines
11 KiB
TypeScript
import React, { ReactElement, useEffect, useState } from 'react';
|
|
import Head from 'next/head';
|
|
import { useRouter } from 'next/router';
|
|
import {
|
|
mdiMagnify,
|
|
mdiMapMarker,
|
|
mdiStar,
|
|
mdiShieldCheck,
|
|
mdiClockOutline,
|
|
mdiCurrencyUsd,
|
|
mdiFilterVariant
|
|
} from '@mdi/js';
|
|
import axios from 'axios';
|
|
import LayoutGuest from '../layouts/Guest';
|
|
import BaseIcon from '../components/BaseIcon';
|
|
import LoadingSpinner from '../components/LoadingSpinner';
|
|
import Link from 'next/link';
|
|
|
|
const SearchView = () => {
|
|
const router = useRouter();
|
|
const { query: searchQueryParam, location: locationParam } = router.query;
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
const [searchQuery, setSearchQuery] = useState(searchQueryParam || '');
|
|
const [location, setLocation] = useState(locationParam || '');
|
|
|
|
useEffect(() => {
|
|
if (searchQueryParam) {
|
|
setSearchQuery(searchQueryParam as string);
|
|
fetchData(searchQueryParam as string);
|
|
}
|
|
}, [searchQueryParam]);
|
|
|
|
const fetchData = async (query: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await axios.post('/search', { searchQuery: query });
|
|
setSearchResults(response.data);
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
router.push({
|
|
pathname: '/search',
|
|
query: { query: searchQuery, location },
|
|
});
|
|
};
|
|
|
|
const businesses = searchResults.filter((item: any) => item.tableName === 'businesses');
|
|
|
|
const getBusinessImage = (biz: any) => {
|
|
if (biz.business_photos_business && biz.business_photos_business.length > 0) {
|
|
const photo = biz.business_photos_business[0].photos && biz.business_photos_business[0].photos[0];
|
|
if (photo && photo.publicUrl) {
|
|
return `/api/file/download?privateUrl=${photo.publicUrl}`;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 pb-20">
|
|
<Head>
|
|
<title>Find Services | Crafted Network™</title>
|
|
</Head>
|
|
|
|
{/* Search Header */}
|
|
<div className="bg-slate-900 pt-32 pb-12 shadow-inner">
|
|
<div className="container mx-auto px-6">
|
|
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 p-2 bg-white/10 backdrop-blur-md rounded-2xl border border-white/10 shadow-xl max-w-4xl mx-auto">
|
|
<div className="flex-grow relative">
|
|
<BaseIcon path={mdiMagnify} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Service (e.g. Plumbing)"
|
|
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
|
/>
|
|
</div>
|
|
<div className="md:w-px h-8 bg-white/10 my-auto"></div>
|
|
<div className="flex-grow relative">
|
|
<BaseIcon path={mdiMapMarker} size={24} className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
value={location}
|
|
onChange={(e) => setLocation(e.target.value)}
|
|
placeholder="Location"
|
|
className="w-full bg-transparent border-none focus:ring-0 py-3 pl-12 pr-4 text-white placeholder-slate-500"
|
|
/>
|
|
</div>
|
|
<button type="submit" className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-8 rounded-xl transition-all">
|
|
Update Search
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="container mx-auto px-6 mt-12">
|
|
<div className="flex flex-col lg:flex-row gap-8">
|
|
|
|
{/* Filters Sidebar */}
|
|
<aside className="w-full lg:w-64 space-y-8">
|
|
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-bold text-lg">Filters</h3>
|
|
<BaseIcon path={mdiFilterVariant} size={20} className="text-slate-400" />
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-3">Availability</label>
|
|
<div className="space-y-2">
|
|
{['Available Today', 'This Week', 'Next Week'].map(label => (
|
|
<label key={label} className="flex items-center text-sm text-slate-600 cursor-pointer hover:text-emerald-600">
|
|
<input type="checkbox" className="rounded text-emerald-500 mr-3 border-slate-300 focus:ring-emerald-500" />
|
|
{label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-3">Reliability Score</label>
|
|
<input type="range" min="0" max="100" className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
|
|
<div className="flex justify-between text-xs text-slate-400 mt-2">
|
|
<span>Any</span>
|
|
<span>80+</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Results Area */}
|
|
<main className="flex-grow">
|
|
<div className="flex items-baseline justify-between mb-8">
|
|
<h2 className="text-2xl font-bold">
|
|
{loading ? 'Searching...' : `${businesses.length} Results for "${searchQueryParam || 'Businesses'}"`}
|
|
</h2>
|
|
<div className="text-sm text-slate-500 font-medium">
|
|
Sort by: <span className="text-slate-900 cursor-pointer hover:text-emerald-500">Reliability Score</span>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-20">
|
|
<LoadingSpinner />
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-6">
|
|
{businesses.map((biz: any) => (
|
|
<Link key={biz.id} href={`/public/businesses-details?id=${biz.id}`}>
|
|
<div className="group bg-white rounded-3xl border border-slate-200 hover:border-emerald-500 hover:shadow-xl transition-all overflow-hidden flex flex-col md:flex-row">
|
|
{/* Image */}
|
|
<div className="md:w-64 h-48 md:h-auto bg-slate-100 relative">
|
|
{getBusinessImage(biz) ? (
|
|
<img
|
|
src={getBusinessImage(biz)}
|
|
alt={biz.name}
|
|
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-slate-300">
|
|
<BaseIcon path={mdiShieldCheck} size={64} />
|
|
</div>
|
|
)}
|
|
{biz.reliability_score >= 80 && (
|
|
<div className="absolute top-4 left-4 bg-emerald-500 text-white text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded-md shadow-lg">
|
|
Top Rated
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-8 flex-grow">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div>
|
|
<h3 className="text-2xl font-bold group-hover:text-emerald-600 transition-colors mb-1">{biz.name}</h3>
|
|
<div className="flex items-center text-slate-500 text-sm">
|
|
<BaseIcon path={mdiMapMarker} size={16} className="mr-1" />
|
|
{biz.city}, {biz.state} {biz.address}
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="flex items-center justify-end text-emerald-500 font-bold text-xl mb-1">
|
|
<BaseIcon path={mdiStar} size={24} className="mr-1 text-amber-400" />
|
|
{biz.rating || ((biz.reliability_score || 0) / 20).toFixed(1)}
|
|
</div>
|
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter">Reliability Score</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-slate-600 line-clamp-2 mb-6 leading-relaxed">
|
|
{biz.description || 'Verified service professional providing high-quality solutions for your needs.'}
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-4 pt-6 border-t border-slate-100">
|
|
<div className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider">
|
|
<BaseIcon path={mdiClockOutline} size={16} className="mr-2 text-emerald-500" />
|
|
~{biz.response_time_median_minutes || 30}m Response
|
|
</div>
|
|
<div className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider">
|
|
<BaseIcon path={mdiCurrencyUsd} size={16} className="mr-2 text-emerald-500" />
|
|
Fair Pricing
|
|
</div>
|
|
<div className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider">
|
|
<BaseIcon path={mdiShieldCheck} size={16} className="mr-2 text-emerald-500" />
|
|
Verified
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
|
|
{businesses.length === 0 && !loading && (
|
|
<div className="bg-white rounded-3xl p-20 text-center border-2 border-dashed border-slate-200">
|
|
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-6 text-slate-300">
|
|
<BaseIcon path={mdiMagnify} size={40} />
|
|
</div>
|
|
<h3 className="text-xl font-bold mb-2">No businesses found</h3>
|
|
<p className="text-slate-500">Try adjusting your search terms or location.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
SearchView.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutGuest>{page}</LayoutGuest>;
|
|
};
|
|
|
|
export default SearchView; |