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>
)
@@ -42,7 +42,7 @@ const copy = {
submit: 'Demander une réservation',
submitting: 'Envoi…',
successTitle: 'Demande reçue !',
successBody: 'Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.',
successBody: "Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.",
invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Veuillez remplir tous les champs obligatoires.',
perDay: '/ jour',
@@ -72,6 +72,7 @@ const copy = {
export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props) {
const { language } = useMarketplacePreferences()
const t = copy[language]
const [firstName, setFirstName] = useState('')
@@ -118,6 +119,7 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
startDate: start.toISOString(),
endDate: end.toISOString(),
notes: notes.trim() || undefined,
language,
})
setSuccess(true)
} catch (err: any) {
@@ -0,0 +1,123 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type LocaleOption = {
value: 'en' | 'fr' | 'ar'
label: string
flag: string
}
type FooterItem = {
label: string
href?: string
}
export default function MarketplaceFooter({
primaryItems,
secondaryItems,
localeLabel,
rightsLabel,
localeOptions,
currentLocale,
onSelectLanguage,
}: {
primaryItems: FooterItem[]
secondaryItems: FooterItem[]
localeLabel: string
rightsLabel: string
localeOptions: LocaleOption[]
currentLocale: LocaleOption
onSelectLanguage: (language: LocaleOption['value']) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
<div className="shell flex flex-col items-center gap-5 text-center">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{primaryItems.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < primaryItems.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{secondaryItems.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -0,0 +1,216 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type Theme = 'light' | 'medium' | 'dark'
type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
marketplace: string
explore: string
signIn: string
ownerSignIn: string
theme: string
light: string
medium: string
dark: string
}
type LanguageMeta = {
value: Language
flag: string
shortLabel: string
}
export default function MarketplaceHeader({
dict,
theme,
setTheme,
companyName,
dashboardUrl,
currentLanguage,
localeOptions,
onSelectLanguage,
}: {
dict: Dictionary
theme: Theme
setTheme: (theme: Theme) => void
companyName: string | null
dashboardUrl: string
currentLanguage: LanguageMeta
localeOptions: LanguageMeta[]
onSelectLanguage: (language: Language) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'medium' as const, label: dict.medium },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const localeMenuPositionClass =
currentLanguage.value === 'ar'
? 'right-0 sm:right-0'
: 'left-0 sm:left-auto sm:right-0'
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
return (
<header className="sticky top-0 z-50 border-b border-stone-200 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-stone-800 dark:bg-stone-950 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
<div className="flex items-center justify-between gap-3">
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</Link>
</div>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<Link
href="/"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.marketplace}
</Link>
<Link
href="/explore"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.explore}
</Link>
<Link
href="/sign-in"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.signIn}
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
className="ml-1 flex items-center gap-1.5 rounded-full bg-stone-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
{companyName.charAt(0).toUpperCase()}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-up`}
className="ml-1 rounded-full bg-stone-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
>
{dict.ownerSignIn}
</Link>
)}
</nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-stone-700 dark:bg-stone-900/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-stone-800 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-1 transition hover:bg-stone-100 dark:hover:bg-stone-800 sm:gap-2 sm:px-2 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="px-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400 sm:px-2 sm:text-[11px] sm:tracking-[0.2em]">
{dict.theme}
</span>
<span className="rounded-full bg-stone-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-amber-400 dark:text-stone-950 sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
)
}
@@ -1,10 +1,9 @@
'use client'
import { ChevronDown, Globe } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import MarketplaceFooter from '@/components/MarketplaceFooter'
import MarketplaceHeader from '@/components/MarketplaceHeader'
import { footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
@@ -30,7 +29,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'Home',
explore: 'Marketplace',
signIn: 'Sign in',
ownerSignIn: 'Create account',
ownerSignIn: 'Create Agency Space',
language: 'Language',
theme: 'Theme',
light: 'Light',
@@ -42,7 +41,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'Accueil',
explore: 'Marketplace',
signIn: 'Connexion',
ownerSignIn: 'Créer un compte',
ownerSignIn: "Créer Espace d'Agence",
language: 'Langue',
theme: 'Mode',
light: 'Clair',
@@ -54,7 +53,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء حساب',
ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
@@ -74,20 +73,15 @@ type PreferencesContextValue = {
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
type FooterItem = {
label: string
href?: string
}
const localeOptions: Array<{ value: MarketplaceLanguage; label: string }> = [
{ value: 'en', label: 'Global (English)' },
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
{ value: 'fr', label: 'Europ (French)' },
const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'NorthAfrica (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europ (French)', flag: '🇫🇷' },
]
function getFooterContent(language: MarketplaceLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }>
localeLabel: string
rightsLabel: string
} {
@@ -150,45 +144,6 @@ function getFooterContent(language: MarketplaceLanguage): {
}
}
function SegmentedControl<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: { value: T; label: string }[]
onChange: (value: T) => void
}) {
return (
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
{label}
</span>
<div className="flex items-center gap-1">
{options.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
}`}
>
{option.label}
</button>
)
})}
</div>
</div>
)
}
function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
@@ -205,19 +160,50 @@ export function useMarketplacePreferences() {
return context
}
export default function MarketplaceShell({ children }: { children: React.ReactNode }) {
export default function MarketplaceShell({
children,
initialLanguage = 'en',
}: {
children: React.ReactNode
initialLanguage?: MarketplaceLanguage
}) {
const pathname = usePathname()
const router = useRouter()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard')
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002/admin')
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguage] = useState<MarketplaceLanguage>('en')
const [theme, setTheme] = useState<Theme>('light')
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>('light')
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>('en')
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
function applyLanguage(nextLanguage: MarketplaceLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('marketplace-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: Theme) {
if (nextTheme === theme) return
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', nextTheme === 'dark' || nextTheme === 'medium')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
}
setThemeState(nextTheme)
}
async function syncCompanyBrand() {
const token = document.cookie
@@ -249,36 +235,63 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
}
}
function syncScopedPreferencesForSession() {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
function readSessionLanguage(): MarketplaceLanguage | null {
try {
const val = sessionStorage.getItem('marketplace-language')
return isMarketplaceLanguage(val) ? val : null
} catch {
return null
}
}
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguage(scopedLanguage)
function syncScopedPreferencesForSession() {
// Tab-session choice always wins — never let an employee-scoped cookie override
// a language the user explicitly picked during this browsing session.
const sessionLang = readSessionLanguage()
if (sessionLang) {
if (sessionLang !== language) setLanguageState(sessionLang)
} else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
}
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if ((scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') && scopedTheme !== theme) {
setTheme(scopedTheme)
setThemeState(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}
useEffect(() => {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (isMarketplaceLanguage(storedLanguage)) {
setLanguage(storedLanguage)
// sessionStorage is tab-local — check it first so a selection made earlier in
// this tab is honoured even after a server refresh.
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
// Write the persistent cookie immediately so any subsequent RSC request
// (e.g. router.refresh or next navigation) sees the correct language
// without waiting for the language effect's render cycle.
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) setLanguage(detected)
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
if (isMarketplaceLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
}
}
}
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
setTheme(storedTheme)
setThemeState(storedTheme)
}
setHydrated(true)
@@ -316,195 +329,77 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
document.body.dataset.theme = theme
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
document.cookie = `${MARKETPLACE_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
if (hydrated && previousLanguage.current !== language) {
// Don't write to storage on the initial render (language='en' default) — wait
// until hydration has resolved the correct stored preference. Writing 'en'
// here before hydration would overwrite a previously-saved 'ar' preference.
if (!hydrated) return
try { sessionStorage.setItem('marketplace-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
if (previousLanguage.current !== language) {
router.refresh()
}
previousLanguage.current = language
}, [hydrated, language, router, theme])
}, [hydrated, language, pathname, router])
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
document.body.dataset.theme = theme
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}, [theme])
const dict = dictionaries[language]
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const currentLocale = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
const isRenterApp = pathname.startsWith('/renter/')
const isEmbeddedWorkspace = isRenterApp
const hideHeader = isRenterApp
const isAuthFrameRoute = pathname === '/sign-in'
const value = useMemo(
() => ({ language, theme, dict, setLanguage, setTheme }),
() => ({ language, theme, dict, setLanguage: applyLanguage, setTheme: applyTheme }),
[dict, language, theme],
)
return (
<PreferencesContext.Provider value={value}>
<div className="min-h-screen bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100">
{!hideHeader ? (
<header className="sticky top-0 z-50 border-b border-stone-200 bg-white/80 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/80">
<div className="shell flex min-h-14 flex-col gap-3 py-3 lg:flex-row lg:items-center lg:justify-between lg:py-2">
<div className="flex items-center justify-between gap-4">
<Link href="/" className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
className="h-9 w-9 rounded-md object-contain"
/>
<span>RentalDriveGo</span>
</Link>
</div>
<div className="flex min-h-screen flex-col bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100">
{isRenterApp || isAuthFrameRoute ? null : (
<MarketplaceHeader
dict={dict}
theme={theme}
setTheme={applyTheme}
companyName={companyName}
dashboardUrl={dashboardUrl}
currentLanguage={{
value: currentLocale.value,
flag: currentLocale.flag,
shortLabel: currentLocale.value.toUpperCase(),
}}
localeOptions={availableLocaleOptions.map((option) => ({
value: option.value,
flag: option.flag,
shortLabel: option.value.toUpperCase(),
}))}
onSelectLanguage={applyLanguage}
/>
)}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<nav className="flex items-center gap-1 overflow-x-auto">
<Link
href="/"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.marketplace}
</Link>
<Link
href="/explore"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.explore}
</Link>
<Link
href="/sign-in"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.signIn}
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
className="ml-2 flex items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
{companyName.charAt(0).toUpperCase()}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-up`}
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{dict.ownerSignIn}
</Link>
)}
</nav>
<SegmentedControl
label={dict.theme}
value={theme}
options={[
{ value: 'light', label: dict.light },
{ value: 'medium', label: dict.medium },
{ value: 'dark', label: dict.dark },
]}
onChange={setTheme}
/>
</div>
</div>
</header>
) : null}
<div className="flex min-h-[calc(100vh-57px)] flex-col">
<div className="flex-1">
{children}
</div>
{!isEmbeddedWorkspace ? (
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
<div className="shell flex flex-col items-center gap-5 text-center">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.secondary.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<Globe className="h-4 w-4" />
<span>{footerContent.localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
) : null}
<div className="flex flex-1 flex-col">
<div className="flex-1">{children}</div>
{isRenterApp || isAuthFrameRoute ? null : (
<MarketplaceFooter
primaryItems={footerContent.primary}
secondaryItems={footerContent.secondary}
localeLabel={footerContent.localeLabel}
rightsLabel={footerContent.rightsLabel}
localeOptions={availableLocaleOptions}
currentLocale={currentLocale}
onSelectLanguage={applyLanguage}
/>
)}
</div>
</div>
</PreferencesContext.Provider>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useMarketplacePreferences } from './MarketplaceShell'
@@ -44,11 +45,13 @@ function buildFrameUrl(appUrl: string, path: string) {
}
export default function WorkspaceFrame({ target }: { target: FrameId }) {
const pathname = usePathname()
const { language, theme } = useMarketplacePreferences()
const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target]
const frameHeight = 'calc(100vh - 56px)'
const isStandaloneAuthRoute = target === 'sign-in' && pathname === '/sign-in'
const frameHeight = isStandaloneAuthRoute ? '100vh' : 'calc(100vh - 56px)'
const loadingLabel = {
en: 'Loading',
fr: 'Chargement de',
@@ -59,6 +62,13 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
setFramePath(getDefaultFramePath(target))
}, [target])
useEffect(() => {
if (!isStandaloneAuthRoute || typeof window === 'undefined') return
if (window.location.pathname !== '/sign-in') return
window.history.replaceState(window.history.state, '', '/')
}, [isStandaloneAuthRoute])
useEffect(() => {
function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
@@ -78,8 +88,13 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath))
nextUrl.searchParams.set('theme', theme)
nextUrl.searchParams.set('lang', language)
if (isStandaloneAuthRoute) {
nextUrl.searchParams.delete('embedded')
} else {
nextUrl.searchParams.set('embedded', '1')
}
setSrc(nextUrl.toString())
}, [config.appUrl, framePath, language, theme])
}, [config.appUrl, framePath, isStandaloneAuthRoute, language, theme])
return (
<main className="bg-stone-950" style={{ minHeight: frameHeight }}>
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
function isValidLanguage(val: string | null | undefined): val is 'en' | 'fr' | 'ar' {
return val === 'en' || val === 'fr' || val === 'ar'
}
function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
if (!header) return 'en'
for (const entry of header.split(',')) {
const code = entry.split(';')[0].trim().split('-')[0].toLowerCase()
if (code === 'ar' || code === 'fr' || code === 'en') return code as 'en' | 'fr' | 'ar'
}
return 'en'
}
export function middleware(request: NextRequest) {
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
// Already has the canonical language cookie — no action needed
if (isValidLanguage(sharedLang)) return NextResponse.next()
const legacyLang = request.cookies.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
const resolved: 'en' | 'fr' | 'ar' = isValidLanguage(legacyLang)
? legacyLang
: detectFromAcceptLanguage(request.headers.get('accept-language'))
// Inject the resolved language into the request cookie header so that
// getMarketplaceLanguage() (which calls cookies()) sees the correct value
// even on this very first render — before the browser has stored the cookie.
const requestHeaders = new Headers(request.headers)
const existing = request.headers.get('cookie') ?? ''
const injected = `${SHARED_LANGUAGE_COOKIE}=${resolved}`
requestHeaders.set('cookie', existing ? `${existing}; ${injected}` : injected)
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Persist in the browser for future requests
response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, {
maxAge: 365 * 24 * 60 * 60,
path: '/',
sameSite: 'lax',
})
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:png|ico|jpg|jpeg|svg|webp)$).*)',
],
}