372 lines
18 KiB
TypeScript
372 lines
18 KiB
TypeScript
import React, { ReactElement, useEffect, useState } from 'react';
|
|
import Head from 'next/head';
|
|
import { useRouter } from 'next/router';
|
|
import {
|
|
mdiStar,
|
|
mdiShieldCheck,
|
|
mdiClockOutline,
|
|
mdiMapMarker,
|
|
mdiPhone,
|
|
mdiWeb,
|
|
mdiEmail,
|
|
mdiCurrencyUsd,
|
|
mdiCheckDecagram,
|
|
mdiMessageDraw,
|
|
mdiAccount
|
|
} from '@mdi/js';
|
|
import axios from 'axios';
|
|
import LayoutGuest from '../../layouts/Guest';
|
|
import BaseIcon from '../../components/BaseIcon';
|
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
|
import dataFormatter from '../../helpers/dataFormatter';
|
|
import { useAppSelector } from '../../stores/hooks';
|
|
|
|
const BusinessDetailsPublic = () => {
|
|
const router = useRouter();
|
|
const { id } = router.query;
|
|
const [loading, setLoading] = useState(true);
|
|
const [business, setBusiness] = useState<any>(null);
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
fetchBusiness();
|
|
}
|
|
}, [id]);
|
|
|
|
const fetchBusiness = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await axios.get(`/businesses/${id}`);
|
|
setBusiness(response.data);
|
|
} catch (error) {
|
|
console.error('Error fetching business:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const claimListing = async () => {
|
|
if (!currentUser) {
|
|
router.push('/login');
|
|
return;
|
|
}
|
|
try {
|
|
await axios.post(`/businesses/${id}/claim`);
|
|
fetchBusiness(); // Refresh data
|
|
} catch (error) {
|
|
console.error('Error claiming business:', error);
|
|
alert('Failed to claim business. Please try again.');
|
|
}
|
|
};
|
|
|
|
const getBusinessImage = () => {
|
|
if (business && business.business_photos_business && business.business_photos_business.length > 0) {
|
|
const photo = business.business_photos_business[0].photos && business.business_photos_business[0].photos[0];
|
|
if (photo && photo.publicUrl) {
|
|
return `/api/file/download?privateUrl=${photo.publicUrl}`;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (loading) return <div className="min-h-screen flex items-center justify-center bg-slate-50"><LoadingSpinner /></div>;
|
|
if (!business) return <div className="min-h-screen flex items-center justify-center bg-slate-50">Business not found.</div>;
|
|
|
|
const displayRating = business.rating ? Number(business.rating).toFixed(1) : 'New';
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 pb-20 pt-20">
|
|
<Head>
|
|
<title>{business.name} | Crafted Network™</title>
|
|
</Head>
|
|
|
|
{/* Hero Header */}
|
|
<section className="bg-white border-b border-slate-200 pt-16 pb-12">
|
|
<div className="container mx-auto px-6">
|
|
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
|
{/* Business Photo */}
|
|
<div className="w-32 h-32 lg:w-48 lg:h-48 bg-slate-100 rounded-[2.5rem] overflow-hidden flex items-center justify-center shadow-inner relative flex-shrink-0">
|
|
{getBusinessImage() ? (
|
|
<img
|
|
src={getBusinessImage()}
|
|
alt={business.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<BaseIcon path={mdiShieldCheck} size={64} className="text-slate-300" />
|
|
)}
|
|
{(business.reliability_score >= 80 || business.is_claimed) && (
|
|
<div className="absolute -top-2 -right-2 bg-emerald-500 text-white p-2 rounded-full shadow-lg">
|
|
<BaseIcon path={mdiCheckDecagram} size={24} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-grow w-full">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-6">
|
|
<div>
|
|
<h1 className="text-4xl lg:text-5xl font-bold mb-3">{business.name}</h1>
|
|
<div className="flex flex-wrap items-center gap-4 text-slate-500 font-medium">
|
|
<span className="flex items-center">
|
|
<BaseIcon path={mdiMapMarker} size={18} className="mr-1 text-emerald-500" />
|
|
{business.city}, {business.state}
|
|
</span>
|
|
<span className="flex items-center">
|
|
<BaseIcon path={mdiStar} size={18} className="mr-1 text-amber-400" />
|
|
{displayRating} Rating
|
|
</span>
|
|
{business.is_claimed ? (
|
|
<span className="bg-emerald-50 text-emerald-600 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider">
|
|
Verified Pro
|
|
</span>
|
|
) : (
|
|
<span className="bg-slate-100 text-slate-500 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center">
|
|
Unclaimed Listing
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={() => router.push(`/public/request-service?businessId=${business.id}`)}
|
|
className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-4 px-8 rounded-2xl transition-all shadow-xl shadow-emerald-500/20"
|
|
>
|
|
Request Service
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 py-6 border-t border-slate-100">
|
|
<div>
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Avg Rating</div>
|
|
<div className="text-2xl font-bold text-slate-900">{displayRating} / 5.0</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Response Time</div>
|
|
<div className="text-2xl font-bold text-slate-900">~{business.response_time_median_minutes || 30}m</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Status</div>
|
|
<div className="flex items-center text-emerald-500 font-bold">
|
|
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2 animate-pulse"></div>
|
|
Available
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Total Reviews</div>
|
|
<div className="text-2xl font-bold text-slate-900">{business.reviews_business?.length || 0}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="container mx-auto px-6 py-12">
|
|
<div className="grid lg:grid-cols-3 gap-12">
|
|
|
|
{/* Main Content */}
|
|
<div className="lg:col-span-2 space-y-12">
|
|
|
|
{!business.is_claimed && (
|
|
<div className="bg-amber-50 border border-amber-200 p-8 rounded-[2rem] flex flex-col md:flex-row items-center justify-between gap-6">
|
|
<div>
|
|
<h4 className="text-xl font-bold text-amber-900 mb-2">Is this your business?</h4>
|
|
<p className="text-amber-700">Claim your listing to respond to reviews, update your profile, and get more leads.</p>
|
|
</div>
|
|
<button
|
|
onClick={claimListing}
|
|
className="bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 px-8 rounded-xl transition-all flex-shrink-0"
|
|
>
|
|
Claim Listing
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Photos Gallery */}
|
|
{business.business_photos_business?.length > 0 && (
|
|
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
|
<h3 className="text-2xl font-bold mb-6">Photos</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
{business.business_photos_business.map((bp: any) => (
|
|
bp.photos?.map((p: any) => (
|
|
<div key={p.id} className="aspect-square rounded-2xl overflow-hidden bg-slate-100">
|
|
<img
|
|
src={`/api/file/download?privateUrl=${p.publicUrl}`}
|
|
alt="Business"
|
|
className="w-full h-full object-cover hover:scale-110 transition-transform duration-500"
|
|
/>
|
|
</div>
|
|
))
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* About */}
|
|
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
|
<h3 className="text-2xl font-bold mb-6">About the Business</h3>
|
|
<div className="text-slate-600 leading-relaxed text-lg"
|
|
dangerouslySetInnerHTML={{ __html: business.description || 'No description provided.' }} />
|
|
</section>
|
|
|
|
{/* Pricing */}
|
|
<section className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
|
<h3 className="text-2xl font-bold mb-6">Service Pricing Range</h3>
|
|
<div className="grid gap-4">
|
|
{business.service_prices_business?.map((price: any) => (
|
|
<div key={price.id} className="flex items-center justify-between p-6 rounded-2xl bg-slate-50 hover:bg-emerald-50 transition-colors group">
|
|
<div>
|
|
<h4 className="font-bold text-slate-800 text-lg group-hover:text-emerald-700">{price.service_name}</h4>
|
|
<p className="text-slate-500 text-sm">{price.notes || 'Standard professional service.'}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-emerald-600 font-bold text-xl">${price.typical_price}</div>
|
|
<div className="text-xs text-slate-400 font-medium">Typical Price</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!business.service_prices_business?.length && <p className="text-slate-500">No pricing information available.</p>}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Reviews */}
|
|
<section>
|
|
<div className="flex items-center justify-between mb-8">
|
|
<h3 className="text-2xl font-bold">Customer Reviews</h3>
|
|
<button
|
|
onClick={() => router.push(`/reviews/reviews-new?businessId=${business.id}`)}
|
|
className="flex items-center gap-2 bg-white border border-slate-200 px-6 py-3 rounded-2xl text-emerald-600 font-bold hover:bg-slate-50 transition-all shadow-sm"
|
|
>
|
|
<BaseIcon path={mdiMessageDraw} size={20} />
|
|
Write a Review
|
|
</button>
|
|
</div>
|
|
<div className="grid gap-6">
|
|
{business.reviews_business?.map((review: any) => (
|
|
<div key={review.id} className="bg-white p-8 rounded-3xl border border-slate-200 shadow-sm hover:shadow-md transition-all">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="flex items-center gap-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<BaseIcon key={i} path={mdiStar} size={18} className={i < review.rating ? 'text-amber-400' : 'text-slate-200'} />
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-slate-400 font-medium">{dataFormatter.dateFormatter(review.created_at_ts)}</span>
|
|
</div>
|
|
<p className="text-slate-700 leading-relaxed mb-4 italic text-lg">"{review.text}"</p>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
|
|
<BaseIcon path={mdiAccount} size={18} />
|
|
</div>
|
|
<span className="text-sm font-bold text-slate-600">
|
|
{review.user?.firstName || 'Anonymous'}
|
|
</span>
|
|
</div>
|
|
{review.is_verified_job && (
|
|
<div className="inline-flex items-center text-[10px] font-bold text-emerald-600 uppercase tracking-widest bg-emerald-50 px-2 py-1 rounded">
|
|
<BaseIcon path={mdiShieldCheck} size={14} className="mr-1" />
|
|
Verified Job
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!business.reviews_business?.length && (
|
|
<div className="text-center py-20 bg-white rounded-[3rem] border border-dashed border-slate-300 text-slate-400">
|
|
<BaseIcon path={mdiMessageDraw} size={48} className="mx-auto mb-4 opacity-20" />
|
|
<p className="text-xl font-medium">No reviews yet.</p>
|
|
<p className="mb-6">Be the first to share your experience!</p>
|
|
<button
|
|
onClick={() => router.push(`/reviews/reviews-new?businessId=${business.id}`)}
|
|
className="bg-emerald-500 hover:bg-emerald-600 text-white font-bold py-3 px-8 rounded-xl"
|
|
>
|
|
Write Review
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-8">
|
|
{/* Contact Info */}
|
|
<div className="bg-slate-900 text-white p-10 rounded-[3rem] shadow-xl relative overflow-hidden group">
|
|
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full -mr-16 -mt-16 group-hover:scale-110 transition-transform"></div>
|
|
<h3 className="text-xl font-bold mb-8">Contact & Location</h3>
|
|
<div className="space-y-6 relative z-10">
|
|
<div className="flex items-start">
|
|
<BaseIcon path={mdiPhone} size={24} className="mr-4 text-emerald-400" />
|
|
<div>
|
|
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Call Now</div>
|
|
<div className="font-bold">{business.phone || 'Contact for details'}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start">
|
|
<BaseIcon path={mdiEmail} size={24} className="mr-4 text-emerald-400" />
|
|
<div>
|
|
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Email</div>
|
|
<div className="font-bold truncate max-w-[180px]">{business.email}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start">
|
|
<BaseIcon path={mdiWeb} size={24} className="mr-4 text-emerald-400" />
|
|
<div>
|
|
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Website</div>
|
|
<div className="font-bold truncate max-w-[180px]">{business.website || 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start">
|
|
<BaseIcon path={mdiMapMarker} size={24} className="mr-4 text-emerald-400" />
|
|
<div>
|
|
<div className="text-xs font-black uppercase tracking-widest text-slate-500 mb-1">Address</div>
|
|
<div className="font-bold">{business.address}, {business.city}, {business.state} {business.zip}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Badges */}
|
|
<div className="bg-white p-10 rounded-[3rem] border border-slate-200 shadow-sm">
|
|
<h3 className="text-xl font-bold mb-8">Trust Signals</h3>
|
|
<div className="space-y-6">
|
|
{business.business_badges_business?.filter((b:any) => b.status === 'APPROVED').map((badge: any) => (
|
|
<div key={badge.id} className="flex items-center p-4 rounded-2xl bg-slate-50">
|
|
<div className="w-10 h-10 bg-emerald-100 rounded-xl flex items-center justify-center mr-4 text-emerald-600">
|
|
<BaseIcon path={mdiShieldCheck} size={24} />
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-slate-800 text-sm leading-tight">{badge.badge_type.replace(/_/g, ' ')}</div>
|
|
<div className="text-[10px] text-slate-400 font-bold uppercase tracking-widest">Verified Badge</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{business.is_claimed && (
|
|
<div className="flex items-center p-4 rounded-2xl bg-emerald-50">
|
|
<div className="w-10 h-10 bg-emerald-200 rounded-xl flex items-center justify-center mr-4 text-emerald-700">
|
|
<BaseIcon path={mdiCheckDecagram} size={24} />
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-emerald-900 text-sm leading-tight">Claimed Listing</div>
|
|
<div className="text-[10px] text-emerald-600 font-bold uppercase tracking-widest">Verified Owner</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!business.business_badges_business?.length && !business.is_claimed && <p className="text-slate-400 text-sm italic">Pending verification...</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
BusinessDetailsPublic.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutGuest>{page}</LayoutGuest>;
|
|
};
|
|
|
|
export default BusinessDetailsPublic; |