update website home and dashboard

This commit is contained in:
root
2026-05-20 14:52:26 -04:00
parent d4f7de5762
commit fb15883f8e
38 changed files with 3043 additions and 1922 deletions
@@ -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 lentreprise sont temporairement indisponibles.',
unavailableBody: 'LAPI marketplace fonctionne, mais la base de données nest 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 jusquau',
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 nest 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>
)
+78 -101
View File
@@ -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>
))}
+4 -2
View File
@@ -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>
)