fix first online resevation

This commit is contained in:
root
2026-05-09 20:01:51 -04:00
parent c4a45c8b21
commit 09b0e3b55f
75 changed files with 6394 additions and 2190 deletions
@@ -0,0 +1,337 @@
'use client'
import { useState } from 'react'
import { marketplacePost } from '@/lib/api'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
photos: string[]
availability?: boolean | null
company: {
slug: string
brand: {
displayName: string
logoUrl: string | null
subdomain: string
marketplaceRating: number | null
} | null
}
}
interface Dict {
rentalCompany: string
checkAvailability: string
available: string
from: string
bookNow: string
details: string
vehicleUnavailable: string
listings: string
availableVehicles: string
// modal
reserveVehicle: string
pickupDate: string
returnDate: 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))
}
const today = () => new Date().toISOString().slice(0, 10)
const tomorrow = () => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().slice(0, 10)
}
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(),
endDate: tomorrow(),
notes: '',
})
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function openModal(vehicle: Vehicle) {
setSelected(vehicle)
setStatus('idle')
setErrorMsg('')
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: today(), endDate: tomorrow(), notes: '' })
}
function closeModal() {
setSelected(null)
setStatus('idle')
setErrorMsg('')
}
function set(field: keyof typeof form) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!selected) 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 || undefined,
startDate: new Date(form.startDate).toISOString(),
endDate: new Date(form.endDate).toISOString(),
notes: form.notes || undefined,
})
setStatus('success')
} catch (err: any) {
setStatus('error')
if (err?.code === 'unavailable') {
setErrorMsg(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
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'}`}>
{vehicle.availability === false ? dict.checkAvailability : dict.available}
</span>
</div>
<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] && (
// 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>
</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>
<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>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3>
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate}</label>
<input
type="date"
required
min={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}</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>
{/* 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}</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}</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>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.email}</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"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.phone}</label>
<input
type="tel"
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>
)}
</>
)
}
+76 -58
View File
@@ -1,7 +1,7 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
import ExploreVehicleGrid from './ExploreVehicleGrid'
interface Vehicle {
id: string
@@ -55,10 +55,12 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
en: {
kicker: 'Discovery only',
title: 'Find your next rental from trusted local companies.',
body: 'Every listing links you to the companys own booking and payment flow.',
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)',
search: 'Search',
currentDeals: 'Current deals',
featuredOffers: 'Featured marketplace offers',
@@ -71,7 +73,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
checkAvailability: 'Check availability',
available: 'Available',
from: 'From',
bookNow: 'Book now',
bookNow: 'Reserve',
details: 'Details',
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category',
@@ -83,19 +85,38 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
publishedVehicles: 'published vehicles',
viewFleet: 'View fleet',
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'],
// modal
reserveVehicle: 'Reserve this vehicle',
pickupDate: 'Pick-up date',
returnDate: 'Return date',
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
phone: 'Phone number (optional)',
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.',
},
fr: {
kicker: 'Découverte uniquement',
title: 'Trouvez votre prochaine location auprès dentreprises locales fiables.',
body: 'Chaque annonce vous redirige vers le parcours de réservation et de paiement propre à lentreprise.',
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)',
search: 'Rechercher',
currentDeals: 'Offres du moment',
featuredOffers: 'Offres marketplace mises en avant',
company: 'Entreprise',
validUntil: 'Valable jusquau',
validUntil: "Valable jusqu'au",
offersUnavailable: 'Les offres marketplace sont indisponibles pour le moment.',
availableVehicles: 'Véhicules disponibles',
listings: 'annonces',
@@ -115,14 +136,32 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
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',
returnDate: 'Date de retour',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
phone: 'Numéro de téléphone (optionnel)',
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.',
},
ar: {
kicker: 'للاستكشاف فقط',
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
body: 'كل إعلان يوجّهك إلى مسار الحجز والدفع الخاص بالشركة نفسها.',
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
city: 'المدينة أو الموقع',
allCategories: 'كل الفئات',
anyTransmission: 'أي ناقل حركة',
makePlaceholder: 'الماركة (مثل Toyota)',
modelPlaceholder: 'الموديل (مثل Corolla)',
search: 'بحث',
currentDeals: 'العروض الحالية',
featuredOffers: 'عروض السوق المميزة',
@@ -135,7 +174,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
checkAvailability: 'تحقق من التوفر',
available: 'متاح',
from: 'ابتداءً من',
bookNow: 'احجز الآن',
bookNow: 'احجز',
details: 'التفاصيل',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
@@ -147,15 +186,37 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
reserveVehicle: 'احجز هذه السيارة',
pickupDate: 'تاريخ الاستلام',
returnDate: 'تاريخ الإرجاع',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'رقم الهاتف (اختياري)',
notes: 'ملاحظات إضافية (اختياري)',
cancel: 'إلغاء',
submitRequest: 'إرسال طلب الحجز',
submitting: 'جارٍ الإرسال…',
successTitle: 'تم إرسال الطلب!',
successBody: 'تم استلام طلب الحجز. تحقق من بريدك الإلكتروني — ستتواصل معك الشركة قريباً.',
close: 'إغلاق',
errorUnavailable: 'السيارة غير متاحة في التواريخ المحددة. يرجى اختيار تواريخ أخرى.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
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 query = new URLSearchParams()
if (city) query.set('city', city)
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([
@@ -171,11 +232,15 @@ 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-4">
<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', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((option) => <option key={option} value={option}>{option}</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>
@@ -203,54 +268,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
</div>
</section>
<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'}`}>
{vehicle.availability === false ? dict.checkAvailability : dict.available}
</span>
</div>
<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">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href={`https://${vehicle.company.brand?.subdomain ?? vehicle.company.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`}
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white"
>
{dict.bookNow}
</a>
<Link href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`} className="rounded-full border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-700">
{dict.details}
</Link>
</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>
<ExploreVehicleGrid vehicles={vehicles} dict={dict} />
<section>
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
+12
View File
@@ -29,3 +29,15 @@ export async function marketplaceFetchOrDefault<T>(path: string, fallback: T): P
return fallback
}
}
export async function marketplacePost<T>(path: string, body: unknown): Promise<T> {
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error)
return json.data as T
}