update website home and dashboard
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
type ExploreSearchFormDict = {
|
||||
searchTitle: string
|
||||
pickupLocation: string
|
||||
dropoffLocation: string
|
||||
pickupDate: string
|
||||
returnDate: string
|
||||
hour: string
|
||||
promoCode: string
|
||||
myAge: string
|
||||
ageDescription: string
|
||||
search: string
|
||||
ageOptions: string[]
|
||||
}
|
||||
|
||||
function toUtcDateTime(date: string, time: string) {
|
||||
if (!date) return ''
|
||||
return `${date}T${time || '10:00'}:00.000Z`
|
||||
}
|
||||
|
||||
function inputClassName(hasLeadingIcon = true) {
|
||||
return `w-full border-0 bg-white px-4 py-4 text-sm text-stone-900 outline-none placeholder:text-stone-400 ${hasLeadingIcon ? 'pl-2' : ''}`
|
||||
}
|
||||
|
||||
function selectClassName() {
|
||||
return 'w-full border-0 bg-white py-4 text-sm text-stone-900 outline-none'
|
||||
}
|
||||
|
||||
export default function ExploreSearchForm({
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
pickupDate,
|
||||
pickupTime,
|
||||
returnDate,
|
||||
returnTime,
|
||||
promoCode,
|
||||
driverAge,
|
||||
cities,
|
||||
dict,
|
||||
}: {
|
||||
pickupLocation: string
|
||||
dropoffLocation: string
|
||||
pickupDate: string
|
||||
pickupTime: string
|
||||
returnDate: string
|
||||
returnTime: string
|
||||
promoCode: string
|
||||
driverAge: string
|
||||
cities: string[]
|
||||
dict: ExploreSearchFormDict
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [filters, setFilters] = useState({
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
pickupDate,
|
||||
pickupTime: pickupTime || '10:00',
|
||||
returnDate,
|
||||
returnTime: returnTime || '10:00',
|
||||
promoCode,
|
||||
driverAge: driverAge || '25+',
|
||||
})
|
||||
const [ageHelpOpen, setAgeHelpOpen] = useState(false)
|
||||
|
||||
function updateFilter(name: keyof typeof filters, value: string) {
|
||||
setFilters((current) => ({ ...current, [name]: value }))
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const query = new URLSearchParams()
|
||||
const normalizedPickup = filters.pickupLocation.trim()
|
||||
const normalizedDropoff = filters.dropoffLocation.trim()
|
||||
const normalizedPromo = filters.promoCode.trim()
|
||||
|
||||
if (normalizedPickup) {
|
||||
query.set('pickupLocation', normalizedPickup)
|
||||
query.set('city', normalizedPickup)
|
||||
}
|
||||
if (normalizedDropoff) query.set('dropoffLocation', normalizedDropoff)
|
||||
if (filters.pickupDate) query.set('pickupDate', filters.pickupDate)
|
||||
if (filters.pickupTime) query.set('pickupTime', filters.pickupTime)
|
||||
if (filters.returnDate) query.set('returnDate', filters.returnDate)
|
||||
if (filters.returnTime) query.set('returnTime', filters.returnTime)
|
||||
if (normalizedPromo) query.set('promoCode', normalizedPromo)
|
||||
if (filters.driverAge) query.set('driverAge', filters.driverAge)
|
||||
|
||||
const startDateTime = toUtcDateTime(filters.pickupDate, filters.pickupTime)
|
||||
const endDateTime = toUtcDateTime(filters.returnDate, filters.returnTime)
|
||||
if (startDateTime) query.set('startDate', startDateTime)
|
||||
if (endDateTime) query.set('endDate', endDateTime)
|
||||
|
||||
const target = query.toString() ? `/explore?${query.toString()}` : '/explore'
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-[1.75rem] bg-white p-5 text-stone-900 shadow-[0_24px_80px_rgba(0,0,0,0.16)]">
|
||||
<h2 className="text-3xl font-black tracking-tight">{dict.searchTitle}</h2>
|
||||
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
||||
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 bg-stone-200 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 bg-white px-4">
|
||||
<MapPin className="h-5 w-5 text-stone-300" />
|
||||
<select
|
||||
name="pickupLocation"
|
||||
value={filters.pickupLocation}
|
||||
onChange={(event) => updateFilter('pickupLocation', event.target.value)}
|
||||
className={selectClassName()}
|
||||
>
|
||||
<option value="">{dict.pickupLocation}</option>
|
||||
{cities.map((city) => (
|
||||
<option key={`pickup-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white px-4">
|
||||
<MapPin className="h-5 w-5 text-stone-300" />
|
||||
<select
|
||||
name="dropoffLocation"
|
||||
value={filters.dropoffLocation}
|
||||
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
|
||||
className={selectClassName()}
|
||||
>
|
||||
<option value="">{dict.dropoffLocation}</option>
|
||||
{cities.map((city) => (
|
||||
<option key={`dropoff-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 bg-stone-200 md:grid-cols-4">
|
||||
<label className="flex items-center gap-3 bg-white px-4">
|
||||
<CalendarDays className="h-5 w-5 text-stone-500" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.pickupDate}</span>
|
||||
<input
|
||||
name="pickupDate"
|
||||
type="date"
|
||||
value={filters.pickupDate}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(event) => updateFilter('pickupDate', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white px-4">
|
||||
<Clock3 className="h-5 w-5 text-stone-500" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.hour}</span>
|
||||
<input
|
||||
name="pickupTime"
|
||||
type="time"
|
||||
value={filters.pickupTime}
|
||||
onChange={(event) => updateFilter('pickupTime', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white px-4">
|
||||
<CalendarDays className="h-5 w-5 text-stone-500" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.returnDate}</span>
|
||||
<input
|
||||
name="returnDate"
|
||||
type="date"
|
||||
value={filters.returnDate}
|
||||
min={filters.pickupDate || new Date().toISOString().slice(0, 10)}
|
||||
onChange={(event) => updateFilter('returnDate', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white px-4">
|
||||
<Clock3 className="h-5 w-5 text-stone-500" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.hour}</span>
|
||||
<input
|
||||
name="returnTime"
|
||||
type="time"
|
||||
value={filters.returnTime}
|
||||
onChange={(event) => updateFilter('returnTime', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid gap-px overflow-visible rounded-[1.35rem] border border-stone-200 bg-stone-200 lg:grid-cols-[1.1fr_1.1fr_1.35fr]">
|
||||
<label className="flex items-center gap-3 rounded-t-[1.35rem] bg-white px-4 lg:rounded-l-[1.35rem] lg:rounded-tr-none">
|
||||
<TicketPercent className="h-5 w-5 text-stone-300" />
|
||||
<input
|
||||
name="promoCode"
|
||||
value={filters.promoCode}
|
||||
onChange={(event) => updateFilter('promoCode', event.target.value)}
|
||||
placeholder={dict.promoCode}
|
||||
className={inputClassName()}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3 bg-white px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="text-sm font-semibold text-stone-600">{dict.myAge}</span>
|
||||
<select
|
||||
name="driverAge"
|
||||
value={filters.driverAge}
|
||||
onChange={(event) => updateFilter('driverAge', event.target.value)}
|
||||
className="border-0 bg-transparent text-sm font-semibold text-stone-900 outline-none"
|
||||
>
|
||||
{dict.ageOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
onMouseEnter={() => setAgeHelpOpen(true)}
|
||||
onMouseLeave={() => setAgeHelpOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={dict.ageDescription}
|
||||
onClick={() => setAgeHelpOpen((open) => !open)}
|
||||
className="text-stone-400 transition hover:text-stone-600"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
{ageHelpOpen ? (
|
||||
<div className="absolute bottom-[calc(100%+0.85rem)] right-0 z-30 w-72 rounded-2xl border border-[#ffc400] bg-white px-4 py-3 text-left text-sm leading-6 text-stone-700 shadow-[0_16px_40px_rgba(0,0,0,0.14)]">
|
||||
{dict.ageDescription}
|
||||
<span className="absolute right-6 top-full h-3 w-3 -translate-y-1/2 rotate-45 border-b border-r border-[#ffc400] bg-white" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="min-h-[76px] rounded-b-[1.35rem] bg-stone-900 px-6 text-sm font-black uppercase tracking-[0.08em] text-white transition hover:bg-stone-700 lg:rounded-b-none lg:rounded-r-[1.35rem]"
|
||||
>
|
||||
{dict.search}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { marketplacePost } from '@/lib/api'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
@@ -33,372 +30,82 @@ interface Dict {
|
||||
maintenance: string
|
||||
unavailableStatus: string
|
||||
from: string
|
||||
bookNow: string
|
||||
viewVehicle: string
|
||||
availableFrom: string
|
||||
reserveAfterDate: string
|
||||
unavailableNoDate: string
|
||||
details: string
|
||||
vehicleUnavailable: string
|
||||
listings: string
|
||||
availableVehicles: string
|
||||
// modal
|
||||
reserveVehicle: string
|
||||
pickupDate: string
|
||||
pickupTime: string
|
||||
returnDate: string
|
||||
dropoffTime: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
notes: string
|
||||
cancel: string
|
||||
submitRequest: string
|
||||
submitting: string
|
||||
successTitle: string
|
||||
successBody: string
|
||||
close: string
|
||||
errorUnavailable: string
|
||||
errorGeneric: string
|
||||
}
|
||||
|
||||
function formatCents(cents: number) {
|
||||
return `${(cents / 100).toLocaleString('fr-MA', { minimumFractionDigits: 2 })} MAD`
|
||||
}
|
||||
|
||||
function daysBetween(start: string, end: string) {
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
return Math.max(1, Math.ceil(ms / 86_400_000))
|
||||
}
|
||||
|
||||
function formatAvailabilityDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
const tomorrow = () => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)
|
||||
function getStatusCopy(vehicle: Vehicle, dict: Dict) {
|
||||
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
|
||||
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
|
||||
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
|
||||
if (vehicle.availability === false) return dict.checkAvailability
|
||||
return dict.available
|
||||
}
|
||||
|
||||
export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) {
|
||||
const [selected, setSelected] = useState<Vehicle | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
startDate: today(),
|
||||
startTime: '10:00',
|
||||
endDate: tomorrow(),
|
||||
endTime: '10:00',
|
||||
notes: '',
|
||||
})
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
function openModal(vehicle: Vehicle) {
|
||||
const minStart = vehicle.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : today()
|
||||
const minEnd = new Date(new Date(minStart).getTime() + 86_400_000).toISOString().slice(0, 10)
|
||||
setSelected(vehicle)
|
||||
setStatus('idle')
|
||||
setErrorMsg('')
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, startTime: '10:00', endDate: minEnd, endTime: '10:00', notes: '' })
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setSelected(null)
|
||||
setStatus('idle')
|
||||
setErrorMsg('')
|
||||
}
|
||||
|
||||
function set(field: keyof typeof form) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((prev) => {
|
||||
if (field !== 'startDate') return { ...prev, [field]: e.target.value }
|
||||
const nextStart = e.target.value
|
||||
const nextEnd = !prev.endDate || prev.endDate <= nextStart
|
||||
? new Date(new Date(nextStart).getTime() + 86_400_000).toISOString().slice(0, 10)
|
||||
: prev.endDate
|
||||
return { ...prev, startDate: nextStart, endDate: nextEnd }
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selected) return
|
||||
const minStart = selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()
|
||||
if (form.startDate < minStart) {
|
||||
setStatus('error')
|
||||
setErrorMsg(`${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt) ?? minStart}.`)
|
||||
return
|
||||
}
|
||||
setStatus('submitting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
await marketplacePost('/marketplace/reservations', {
|
||||
vehicleId: selected.id,
|
||||
companySlug: selected.company.slug,
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
startDate: new Date(`${form.startDate}T${form.startTime}`).toISOString(),
|
||||
endDate: new Date(`${form.endDate}T${form.endTime}`).toISOString(),
|
||||
notes: form.notes || undefined,
|
||||
})
|
||||
setStatus('success')
|
||||
} catch (err: any) {
|
||||
setStatus('error')
|
||||
if (err?.code === 'unavailable') {
|
||||
setErrorMsg(err?.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(err.nextAvailableAt)}.` : dict.errorUnavailable)
|
||||
} else {
|
||||
setErrorMsg(err?.message ?? dict.errorGeneric)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const days = form.startDate && form.endDate ? daysBetween(form.startDate, form.endDate) : 0
|
||||
const estimatedTotal = selected ? selected.dailyRate * days : 0
|
||||
|
||||
function getStatusCopy(vehicle: Vehicle) {
|
||||
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
|
||||
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
|
||||
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
|
||||
if (vehicle.availability === false) return dict.checkAvailability
|
||||
return dict.available
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2>
|
||||
<p className="text-sm text-stone-500">{vehicles.length} {dict.listings}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card overflow-hidden">
|
||||
<div className="aspect-[16/10] bg-stone-100">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||
{getStatusCopy(vehicle)}
|
||||
</span>
|
||||
</div>
|
||||
{vehicle.availability === false && (
|
||||
<p className="mt-3 text-sm text-stone-500">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-500">{dict.from}</p>
|
||||
<p className="text-2xl font-black text-stone-900">{formatCents(vehicle.dailyRate)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => openModal(vehicle)}
|
||||
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.bookNow}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{vehicles.length === 0 && (
|
||||
<div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selected && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={closeModal}>
|
||||
<div
|
||||
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-3xl bg-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 p-6 border-b border-stone-100">
|
||||
{selected.photos[0] && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2>
|
||||
<p className="text-sm text-stone-500">{vehicles.length} {dict.listings}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card overflow-hidden">
|
||||
<div className="aspect-[16/10] bg-stone-100">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={selected.photos[0]} alt="" className="h-16 w-24 rounded-xl object-cover flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs uppercase tracking-widest text-stone-400">{selected.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h2 className="mt-1 text-xl font-bold text-stone-900 truncate">{selected.year} {selected.make} {selected.model}</h2>
|
||||
<p className="mt-0.5 text-sm text-stone-500">{selected.category} · {formatCents(selected.dailyRate)}/day</p>
|
||||
</div>
|
||||
<button onClick={closeModal} className="flex-shrink-0 rounded-full p-2 hover:bg-stone-100 text-stone-500">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" /></svg>
|
||||
</button>
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{status === 'success' ? (
|
||||
<div className="p-8 text-center space-y-4">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
|
||||
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-stone-900">{dict.successTitle}</h3>
|
||||
<p className="text-stone-500">{dict.successBody}</p>
|
||||
<button onClick={closeModal} className="mt-4 rounded-full bg-stone-900 px-8 py-3 text-sm font-semibold text-white">
|
||||
{dict.close}
|
||||
</button>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||
{getStatusCopy(vehicle, dict)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3>
|
||||
{selected.availability === false && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{selected.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt)}.` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates + Times */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
min={selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()}
|
||||
value={form.startDate}
|
||||
onChange={set('startDate')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.returnDate} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
min={form.startDate || today()}
|
||||
value={form.endDate}
|
||||
onChange={set('endDate')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupTime} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={form.startTime}
|
||||
onChange={set('startTime')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.dropoffTime} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={form.endTime}
|
||||
onChange={set('endTime')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated total */}
|
||||
{days > 0 && (
|
||||
<div className="rounded-2xl bg-stone-50 px-4 py-3 flex items-center justify-between">
|
||||
<p className="text-sm text-stone-500">{days} day{days > 1 ? 's' : ''} × {formatCents(selected.dailyRate)}</p>
|
||||
<p className="font-bold text-stone-900">{formatCents(estimatedTotal)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.firstName} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.firstName}
|
||||
onChange={set('firstName')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.lastName} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.lastName}
|
||||
onChange={set('lastName')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehicle.availability === false && (
|
||||
<p className="mt-3 text-sm text-stone-500">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex items-end justify-between">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.email} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={set('email')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
<p className="text-sm text-stone-500">{dict.from}</p>
|
||||
<p className="text-2xl font-black text-stone-900">{formatCents(vehicle.dailyRate)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.phone} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={set('phone')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.notes}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.notes}
|
||||
onChange={set('notes')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rounded-2xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="flex-1 rounded-full border border-stone-300 px-4 py-3 text-sm font-semibold text-stone-700"
|
||||
>
|
||||
{dict.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="flex-1 rounded-full bg-stone-900 px-4 py-3 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{status === 'submitting' ? dict.submitting : dict.submitRequest}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Link
|
||||
href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`}
|
||||
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700"
|
||||
>
|
||||
{dict.viewVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{vehicles.length === 0 && (
|
||||
<div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailableTitle: 'Les détails de l’entreprise sont temporairement indisponibles.',
|
||||
unavailableBody: 'L’API marketplace fonctionne, mais la base de données n’est pas accessible dans cet environnement local.',
|
||||
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
|
||||
unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
||||
backToExplore: 'Retour à explorer',
|
||||
partner: 'Partenaire marketplace',
|
||||
vehicles: 'véhicules',
|
||||
@@ -52,7 +52,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
currentOffers: 'Offres actuelles',
|
||||
validUntil: 'Valable jusqu’au',
|
||||
validUntil: "Valable jusqu'au",
|
||||
noOffers: 'Aucune offre active pour le moment.',
|
||||
fleet: 'Flotte',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
|
||||
@@ -33,39 +33,76 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
const language = getMarketplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: ‘Marketplace unavailable’,
|
||||
unavailableTitle: ‘Vehicle details are temporarily unavailable.’,
|
||||
unavailableBody: ‘The marketplace frontend is running, but the backing database is not reachable right now.’,
|
||||
backToFleet: ‘Back to company fleet’,
|
||||
noPhotos: ‘No photos available.’,
|
||||
rentalCompany: ‘Rental company’,
|
||||
perDay: ‘/ day’,
|
||||
availableFrom: ‘Available from’,
|
||||
unavailableNoDate: ‘This vehicle is temporarily unavailable for new reservations.’,
|
||||
unavailable: 'Marketplace unavailable',
|
||||
unavailableTitle: 'Vehicle details are temporarily unavailable.',
|
||||
unavailableBody: 'The marketplace frontend is running, but the backing database is not reachable right now.',
|
||||
backToFleet: 'Back to fleet',
|
||||
backToExplore: 'Back to explore',
|
||||
noPhotos: 'No photos available.',
|
||||
rentalCompany: 'Rental company',
|
||||
perDay: '/ day',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
specs: 'Specifications',
|
||||
year: 'Year',
|
||||
category: 'Category',
|
||||
seats: 'Seats',
|
||||
transmission: 'Transmission',
|
||||
fuel: 'Fuel type',
|
||||
features: 'Features & extras',
|
||||
contact: 'Contact company',
|
||||
phone: 'Phone',
|
||||
whatsapp: 'WhatsApp',
|
||||
photos: 'Photos',
|
||||
},
|
||||
fr: {
|
||||
unavailable: ‘Marketplace indisponible’,
|
||||
unavailableTitle: ‘Les détails du véhicule sont temporairement indisponibles.’,
|
||||
unavailableBody: ‘Le frontend marketplace fonctionne, mais la base de données n’est pas accessible pour le moment.’,
|
||||
backToFleet: ‘Retour à la flotte’,
|
||||
noPhotos: ‘Aucune photo disponible.’,
|
||||
rentalCompany: ‘Société de location’,
|
||||
perDay: ‘/ jour’,
|
||||
availableFrom: ‘Disponible à partir du’,
|
||||
unavailableNoDate: ‘Ce véhicule est temporairement indisponible pour les nouvelles réservations.’,
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||
unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
||||
backToFleet: 'Retour à la flotte',
|
||||
backToExplore: 'Retour à explorer',
|
||||
noPhotos: 'Aucune photo disponible.',
|
||||
rentalCompany: 'Société de location',
|
||||
perDay: '/ jour',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
specs: 'Caractéristiques',
|
||||
year: 'Année',
|
||||
category: 'Catégorie',
|
||||
seats: 'Places',
|
||||
transmission: 'Transmission',
|
||||
fuel: 'Carburant',
|
||||
features: 'Équipements & extras',
|
||||
contact: 'Contacter la société',
|
||||
phone: 'Téléphone',
|
||||
whatsapp: 'WhatsApp',
|
||||
photos: 'Photos',
|
||||
},
|
||||
ar: {
|
||||
unavailable: ‘السوق غير متاح’,
|
||||
unavailableTitle: ‘تفاصيل السيارة غير متاحة مؤقتاً.’,
|
||||
unavailableBody: ‘واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.’,
|
||||
backToFleet: ‘العودة إلى أسطول الشركة’,
|
||||
noPhotos: ‘لا توجد صور متاحة.’,
|
||||
rentalCompany: ‘شركة تأجير’,
|
||||
perDay: ‘/ يوم’,
|
||||
availableFrom: ‘متاح ابتداءً من’,
|
||||
unavailableNoDate: ‘هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.’,
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.',
|
||||
backToFleet: 'العودة إلى الأسطول',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
noPhotos: 'لا توجد صور متاحة.',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
perDay: '/ يوم',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
specs: 'المواصفات',
|
||||
year: 'السنة',
|
||||
category: 'الفئة',
|
||||
seats: 'المقاعد',
|
||||
transmission: 'ناقل الحركة',
|
||||
fuel: 'نوع الوقود',
|
||||
features: 'المميزات والإضافات',
|
||||
contact: 'تواصل مع الشركة',
|
||||
phone: 'الهاتف',
|
||||
whatsapp: 'واتساب',
|
||||
photos: 'الصور',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
@@ -74,10 +111,11 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
<div className="shell">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{dict.unavailable}</p>
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600">{dict.unavailableBody}</p>
|
||||
<div className="mt-6">
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link href={`/explore/${params.slug}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.backToFleet}</Link>
|
||||
<Link href="/explore" className="rounded-full border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-700 dark:border-stone-600 dark:text-stone-300">{dict.backToExplore}</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -89,48 +127,150 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: null
|
||||
|
||||
const [heroPhoto, ...galleryPhotos] = vehicle.photos
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{vehicle.photos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="card overflow-hidden">
|
||||
<main className="min-h-screen bg-stone-50 dark:bg-stone-950 py-10">
|
||||
<div className="shell space-y-8">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
<Link href="/explore" className="hover:text-stone-900 dark:hover:text-stone-100 transition">{dict.backToExplore}</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/explore/${params.slug}`} className="hover:text-stone-900 dark:hover:text-stone-100 transition">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-900 dark:text-stone-100 font-medium">{vehicle.make} {vehicle.model}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
|
||||
|
||||
{/* Left — photos + specs */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Hero photo */}
|
||||
{heroPhoto ? (
|
||||
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-stone-800 aspect-[16/9]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 1}`} className="h-full w-full object-cover" />
|
||||
<img src={heroPhoto} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
{vehicle.photos.length === 0 && <div className="card p-8 text-sm text-stone-500">{dict.noPhotos}</div>}
|
||||
</div>
|
||||
</section>
|
||||
<aside className="card p-6 h-fit space-y-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
|
||||
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p>
|
||||
{!vehicle.availability && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-stone-800 aspect-[16/9] flex items-center justify-center text-sm text-stone-400">{dict.noPhotos}</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{galleryPhotos.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">{dict.photos}</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{galleryPhotos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="overflow-hidden rounded-2xl bg-stone-100 dark:bg-stone-800 aspect-[4/3]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
|
||||
))}
|
||||
|
||||
{/* Specs */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.specs}</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.year}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.year}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.category}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.category}</dd>
|
||||
</div>
|
||||
{vehicle.seats > 0 && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.seats}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.seats}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.transmission && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.transmission}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.transmission}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.fuelType && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.fuel}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.fuelType}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{vehicle.features.length > 0 && (
|
||||
<div className="pt-2 border-t border-stone-100 dark:border-stone-800">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500 mb-3">{dict.features}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 dark:bg-stone-800 px-3 py-1 text-xs font-medium text-stone-700 dark:text-stone-300">{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
{(vehicle.company.brand?.publicPhone || vehicle.company.brand?.whatsappNumber) && (
|
||||
<div className="card p-6 space-y-3">
|
||||
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.contact}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{vehicle.company.brand?.publicPhone && (
|
||||
<a href={`tel:${vehicle.company.brand.publicPhone}`} className="inline-flex items-center gap-2 rounded-full border border-stone-300 dark:border-stone-700 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-stone-800 transition">
|
||||
{dict.phone}: {vehicle.company.brand.publicPhone}
|
||||
</a>
|
||||
)}
|
||||
{vehicle.company.brand?.whatsappNumber && (
|
||||
<a href={`https://wa.me/${vehicle.company.brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 transition">
|
||||
{dict.whatsapp}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vehicle.availability ? (
|
||||
<BookingForm
|
||||
vehicleId={vehicle.id}
|
||||
companySlug={params.slug}
|
||||
dailyRate={vehicle.dailyRate}
|
||||
/>
|
||||
) : null}
|
||||
{/* Right — sticky booking panel */}
|
||||
<aside className="space-y-4">
|
||||
<div className="card p-6 space-y-4 lg:sticky lg:top-20">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h1 className="text-3xl font-black text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
|
||||
<p className="text-3xl font-black text-stone-900 dark:text-stone-100">
|
||||
{formatCurrency(vehicle.dailyRate, 'MAD')}
|
||||
<span className="text-base font-medium text-stone-500 dark:text-stone-400"> {dict.perDay}</span>
|
||||
</p>
|
||||
|
||||
<Link href={`/explore/${params.slug}`} className="block rounded-full border border-stone-300 px-6 py-3 text-center text-sm font-semibold text-stone-700">{dict.backToFleet}</Link>
|
||||
</aside>
|
||||
{!vehicle.availability && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
|
||||
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vehicle.availability && (
|
||||
<BookingForm
|
||||
vehicleId={vehicle.id}
|
||||
companySlug={params.slug}
|
||||
dailyRate={vehicle.dailyRate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/explore/${params.slug}`}
|
||||
className="block rounded-full border border-stone-300 dark:border-stone-700 px-6 py-3 text-center text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-stone-800 transition"
|
||||
>
|
||||
{dict.backToFleet}
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import ExploreSearchForm from './ExploreSearchForm'
|
||||
import ExploreVehicleGrid from './ExploreVehicleGrid'
|
||||
|
||||
interface Vehicle {
|
||||
@@ -51,6 +52,19 @@ interface CompanyCard {
|
||||
}
|
||||
}
|
||||
|
||||
type MarketplaceCity = string
|
||||
|
||||
const CATEGORY_VALUES = [
|
||||
'ECONOMY',
|
||||
'COMPACT',
|
||||
'MIDSIZE',
|
||||
'FULLSIZE',
|
||||
'SUV',
|
||||
'LUXURY',
|
||||
'VAN',
|
||||
'TRUCK',
|
||||
] as const
|
||||
|
||||
export default async function ExplorePage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
|
||||
const language = getMarketplaceLanguage()
|
||||
const dict = {
|
||||
@@ -58,11 +72,16 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
kicker: 'Discovery only',
|
||||
title: 'Find your next rental from trusted local companies.',
|
||||
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
|
||||
city: 'City or location',
|
||||
allCategories: 'All categories',
|
||||
anyTransmission: 'Any transmission',
|
||||
makePlaceholder: 'Make (e.g. Toyota)',
|
||||
modelPlaceholder: 'Model (e.g. Corolla)',
|
||||
searchTitle: 'Book a vehicle',
|
||||
pickupLocation: 'Pick-up location',
|
||||
dropoffLocation: 'Drop-off location',
|
||||
pickupDate: 'Start date',
|
||||
returnDate: 'Return date',
|
||||
hour: 'Hour',
|
||||
promoCode: 'I have a promo code',
|
||||
myAge: 'My age',
|
||||
ageDescription: 'We use your age to estimate your rental price more accurately. Some age groups may have extra fees.',
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Search',
|
||||
currentDeals: 'Current deals',
|
||||
featuredOffers: 'Featured marketplace offers',
|
||||
@@ -78,11 +97,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
maintenance: 'In maintenance',
|
||||
unavailableStatus: 'Unavailable',
|
||||
from: 'From',
|
||||
bookNow: 'Reserve',
|
||||
viewVehicle: 'View vehicle',
|
||||
availableFrom: 'Available from',
|
||||
reserveAfterDate: 'This vehicle can only be reserved starting',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
details: 'Details',
|
||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||
browseByCategory: 'Browse by category',
|
||||
browseByCompany: 'Browse by company',
|
||||
@@ -92,36 +109,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
new: 'New',
|
||||
publishedVehicles: 'published vehicles',
|
||||
viewFleet: 'View fleet',
|
||||
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'],
|
||||
// modal
|
||||
reserveVehicle: 'Reserve this vehicle',
|
||||
pickupDate: 'Pick-up date',
|
||||
pickupTime: 'Pick-up time',
|
||||
returnDate: 'Return date',
|
||||
dropoffTime: 'Drop-off time',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email address',
|
||||
phone: 'Phone number',
|
||||
notes: 'Additional notes (optional)',
|
||||
cancel: 'Cancel',
|
||||
submitRequest: 'Send reservation request',
|
||||
submitting: 'Sending…',
|
||||
successTitle: 'Request sent!',
|
||||
successBody: 'Your reservation request has been received. Check your inbox for a confirmation email — the company will contact you shortly.',
|
||||
close: 'Close',
|
||||
errorUnavailable: 'This vehicle is no longer available for the selected dates. Please choose different dates.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Découverte uniquement',
|
||||
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
|
||||
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
|
||||
city: 'Ville ou emplacement',
|
||||
allCategories: 'Toutes les catégories',
|
||||
anyTransmission: 'Toute transmission',
|
||||
makePlaceholder: 'Marque (ex. Toyota)',
|
||||
modelPlaceholder: 'Modèle (ex. Corolla)',
|
||||
searchTitle: 'Réserver un véhicule',
|
||||
pickupLocation: 'Lieu de départ',
|
||||
dropoffLocation: "Lieu de retour",
|
||||
pickupDate: 'Date de départ',
|
||||
returnDate: 'Date de retour',
|
||||
hour: 'Heure',
|
||||
promoCode: "J'ai un code promo",
|
||||
myAge: 'Mon âge',
|
||||
ageDescription: "Nous utilisons votre âge pour estimer plus précisément le prix de location. Des frais supplémentaires peuvent s'appliquer selon la tranche d'âge.",
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Rechercher',
|
||||
currentDeals: 'Offres du moment',
|
||||
featuredOffers: 'Offres marketplace mises en avant',
|
||||
@@ -137,11 +140,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
maintenance: 'En maintenance',
|
||||
unavailableStatus: 'Indisponible',
|
||||
from: 'À partir de',
|
||||
bookNow: 'Réserver',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
reserveAfterDate: 'Ce véhicule ne peut être réservé qu’à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
details: 'Détails',
|
||||
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
||||
browseByCategory: 'Parcourir par catégorie',
|
||||
browseByCompany: 'Parcourir par entreprise',
|
||||
@@ -151,35 +152,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
new: 'Nouveau',
|
||||
publishedVehicles: 'véhicules publiés',
|
||||
viewFleet: 'Voir la flotte',
|
||||
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
|
||||
reserveVehicle: 'Réserver ce véhicule',
|
||||
pickupDate: 'Date de prise en charge',
|
||||
pickupTime: 'Heure de prise en charge',
|
||||
returnDate: 'Date de retour',
|
||||
dropoffTime: 'Heure de restitution',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Adresse e-mail',
|
||||
phone: 'Numéro de téléphone',
|
||||
notes: 'Notes supplémentaires (optionnel)',
|
||||
cancel: 'Annuler',
|
||||
submitRequest: 'Envoyer la demande',
|
||||
submitting: 'Envoi…',
|
||||
successTitle: 'Demande envoyée !',
|
||||
successBody: "Votre demande de réservation a été reçue. Vérifiez votre boîte mail — l'entreprise vous contactera prochainement.",
|
||||
close: 'Fermer',
|
||||
errorUnavailable: "Ce véhicule n'est plus disponible pour les dates sélectionnées.",
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'للاستكشاف فقط',
|
||||
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
||||
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
|
||||
city: 'المدينة أو الموقع',
|
||||
allCategories: 'كل الفئات',
|
||||
anyTransmission: 'أي ناقل حركة',
|
||||
makePlaceholder: 'الماركة (مثل Toyota)',
|
||||
modelPlaceholder: 'الموديل (مثل Corolla)',
|
||||
searchTitle: 'احجز سيارة',
|
||||
pickupLocation: 'موقع الاستلام',
|
||||
dropoffLocation: 'موقع الإرجاع',
|
||||
pickupDate: 'تاريخ البدء',
|
||||
returnDate: 'تاريخ الإرجاع',
|
||||
hour: 'الساعة',
|
||||
promoCode: 'لدي رمز ترويجي',
|
||||
myAge: 'عمري',
|
||||
ageDescription: 'نستخدم عمرك لتقديم تقدير أدق لسعر الإيجار. قد يتم تطبيق رسوم إضافية لبعض الفئات العمرية.',
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'بحث',
|
||||
currentDeals: 'العروض الحالية',
|
||||
featuredOffers: 'عروض السوق المميزة',
|
||||
@@ -195,11 +183,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
maintenance: 'قيد الصيانة',
|
||||
unavailableStatus: 'غير متاح',
|
||||
from: 'ابتداءً من',
|
||||
bookNow: 'احجز',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
details: 'التفاصيل',
|
||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||
browseByCategory: 'تصفح حسب الفئة',
|
||||
browseByCompany: 'تصفح حسب الشركة',
|
||||
@@ -209,46 +195,41 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
new: 'جديد',
|
||||
publishedVehicles: 'سيارات منشورة',
|
||||
viewFleet: 'عرض الأسطول',
|
||||
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
|
||||
reserveVehicle: 'احجز هذه السيارة',
|
||||
pickupDate: 'تاريخ الاستلام',
|
||||
pickupTime: 'وقت الاستلام',
|
||||
returnDate: 'تاريخ الإرجاع',
|
||||
dropoffTime: 'وقت الإرجاع',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'رقم الهاتف',
|
||||
notes: 'ملاحظات إضافية (اختياري)',
|
||||
cancel: 'إلغاء',
|
||||
submitRequest: 'إرسال طلب الحجز',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
successTitle: 'تم إرسال الطلب!',
|
||||
successBody: 'تم استلام طلب الحجز. تحقق من بريدك الإلكتروني — ستتواصل معك الشركة قريباً.',
|
||||
close: 'إغلاق',
|
||||
errorUnavailable: 'السيارة غير متاحة في التواريخ المحددة. يرجى اختيار تواريخ أخرى.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
|
||||
},
|
||||
}[language]
|
||||
|
||||
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
|
||||
const pickupLocation = typeof searchParams?.pickupLocation === 'string' ? searchParams.pickupLocation : city
|
||||
const dropoffLocation = typeof searchParams?.dropoffLocation === 'string' ? searchParams.dropoffLocation : ''
|
||||
const pickupDate = typeof searchParams?.pickupDate === 'string' ? searchParams.pickupDate : ''
|
||||
const pickupTime = typeof searchParams?.pickupTime === 'string' ? searchParams.pickupTime : '10:00'
|
||||
const returnDate = typeof searchParams?.returnDate === 'string' ? searchParams.returnDate : ''
|
||||
const returnTime = typeof searchParams?.returnTime === 'string' ? searchParams.returnTime : '10:00'
|
||||
const promoCode = typeof searchParams?.promoCode === 'string' ? searchParams.promoCode : ''
|
||||
const driverAge = typeof searchParams?.driverAge === 'string' ? searchParams.driverAge : '25+'
|
||||
const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
|
||||
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
|
||||
const make = typeof searchParams?.make === 'string' ? searchParams.make : ''
|
||||
const model = typeof searchParams?.model === 'string' ? searchParams.model : ''
|
||||
const startDate = typeof searchParams?.startDate === 'string' ? searchParams.startDate : ''
|
||||
const endDate = typeof searchParams?.endDate === 'string' ? searchParams.endDate : ''
|
||||
|
||||
const query = new URLSearchParams()
|
||||
if (city) query.set('city', city)
|
||||
if (pickupLocation) query.set('city', pickupLocation)
|
||||
if (startDate) query.set('startDate', startDate)
|
||||
if (endDate) query.set('endDate', endDate)
|
||||
if (category) query.set('category', category)
|
||||
if (transmission) query.set('transmission', transmission)
|
||||
if (make) query.set('make', make)
|
||||
if (model) query.set('model', model)
|
||||
query.set('pageSize', '24')
|
||||
|
||||
const [offers, vehicles, companies] = await Promise.all([
|
||||
const [offers, vehicles, companies, cities] = await Promise.all([
|
||||
marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []),
|
||||
marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []),
|
||||
marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/companies?pageSize=8', []),
|
||||
marketplaceFetchOrDefault<MarketplaceCity[]>('/marketplace/cities', []),
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -258,22 +239,18 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-amber-300">{dict.kicker}</p>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
|
||||
<p className="mt-4 max-w-2xl text-stone-300">{dict.body}</p>
|
||||
<form className="mt-8 grid gap-3 rounded-[1.5rem] bg-white/10 p-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
<input name="city" defaultValue={city} placeholder={dict.city} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
|
||||
<input name="make" defaultValue={make} placeholder={dict.makePlaceholder} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
|
||||
<input name="model" defaultValue={model} placeholder={dict.modelPlaceholder} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" />
|
||||
<select name="category" defaultValue={category} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
|
||||
<option value="">{dict.allCategories}</option>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="transmission" defaultValue={transmission} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
|
||||
<option value="">{dict.anyTransmission}</option>
|
||||
{['AUTOMATIC', 'MANUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<button className="rounded-2xl bg-amber-400 px-4 py-3 text-sm font-semibold text-stone-950">{dict.search}</button>
|
||||
</form>
|
||||
<ExploreSearchForm
|
||||
pickupLocation={pickupLocation}
|
||||
dropoffLocation={dropoffLocation}
|
||||
pickupDate={pickupDate}
|
||||
pickupTime={pickupTime}
|
||||
returnDate={returnDate}
|
||||
returnTime={returnTime}
|
||||
promoCode={promoCode}
|
||||
driverAge={driverAge}
|
||||
cities={cities}
|
||||
dict={dict}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -300,7 +277,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{dict.categories.map((categoryName, index) => (
|
||||
<Link key={categoryName} href={`/explore?category=${['ECONOMY', 'COMPACT', 'SUV', 'LUXURY', 'VAN', 'TRUCK', 'ELECTRIC'][index]}`} className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
<Link key={CATEGORY_VALUES[index] ?? categoryName} href={`/explore?category=${CATEGORY_VALUES[index]}`} className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
|
||||
{categoryName}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import MarketplaceShell from '@/components/MarketplaceShell'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -13,10 +14,11 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = getMarketplaceLanguage()
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>
|
||||
<MarketplaceShell>{children}</MarketplaceShell>
|
||||
<MarketplaceShell initialLanguage={language}>{children}</MarketplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user