add car reservation feature

This commit is contained in:
root
2026-05-09 21:10:38 -04:00
parent 09b0e3b55f
commit 6322b7d2a1
37 changed files with 1169 additions and 119 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type MarketplaceHomepageConfig,
type MarketplaceHomepageSectionType,
} from '@rentaldrivego/types'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
@@ -16,7 +17,7 @@ export default function HomeContent() {
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
const dict = content[language]
const sections = resolveMarketplaceHomepageSections(dict.sections)
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY&currency=MAD`
useEffect(() => {
@@ -1,5 +1,14 @@
import WorkspaceFrame from '@/components/WorkspaceFrame'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function CompanyWorkspacePage() {
return <WorkspaceFrame target="dashboard" />
const requestHeaders = headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/dashboard`)
}
@@ -12,6 +12,8 @@ interface Vehicle {
dailyRate: number
photos: string[]
availability?: boolean | null
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt?: string | null
company: {
slug: string
brand: {
@@ -27,8 +29,14 @@ interface Dict {
rentalCompany: string
checkAvailability: string
available: string
reserved: string
maintenance: string
unavailableStatus: string
from: string
bookNow: string
availableFrom: string
reserveAfterDate: string
unavailableNoDate: string
details: string
vehicleUnavailable: string
listings: string
@@ -61,13 +69,14 @@ function daysBetween(start: string, end: string) {
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)
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)
export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) {
const [selected, setSelected] = useState<Vehicle | null>(null)
const [form, setForm] = useState({
@@ -83,10 +92,12 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
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: today(), endDate: tomorrow(), notes: '' })
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, endDate: minEnd, notes: '' })
}
function closeModal() {
@@ -97,12 +108,25 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
function set(field: keyof typeof form) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value }))
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 {
@@ -121,7 +145,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
} catch (err: any) {
setStatus('error')
if (err?.code === 'unavailable') {
setErrorMsg(dict.errorUnavailable)
setErrorMsg(err?.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(err.nextAvailableAt)}.` : dict.errorUnavailable)
} else {
setErrorMsg(err?.message ?? dict.errorGeneric)
}
@@ -131,6 +155,14 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
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>
@@ -155,9 +187,14 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
<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}
{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>
@@ -217,6 +254,11 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
) : (
<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 */}
<div className="grid grid-cols-2 gap-3">
@@ -225,7 +267,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
<input
type="date"
required
min={today()}
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"
@@ -16,7 +16,7 @@ interface CompanyProfile {
whatsappNumber: string | null
marketplaceRating: number | null
} | null
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string }>
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string; availability: boolean; nextAvailableAt: string | null }>
offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }>
}
@@ -38,6 +38,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
noOffers: 'No active offers right now.',
fleet: 'Fleet',
viewVehicle: 'View vehicle',
availableFrom: 'Available from',
unavailableNoDate: 'Temporarily unavailable for new reservations.',
},
fr: {
unavailable: 'Marketplace indisponible',
@@ -54,6 +56,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
noOffers: 'Aucune offre active pour le moment.',
fleet: 'Flotte',
viewVehicle: 'Voir le véhicule',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Temporairement indisponible pour les nouvelles réservations.',
},
ar: {
unavailable: 'السوق غير متاح',
@@ -70,6 +74,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
noOffers: 'لا توجد عروض نشطة حالياً.',
fleet: 'الأسطول',
viewVehicle: 'عرض السيارة',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
},
}[language]
const company = await marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${params.slug}`, null)
@@ -133,6 +139,11 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
<div className="p-5">
<h3 className="text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 text-sm text-stone-500">{vehicle.category}</p>
{!vehicle.availability && (
<p className="mt-2 text-sm text-stone-500">
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}` : dict.unavailableNoDate}
</p>
)}
<div className="mt-4 flex items-end justify-between">
<p className="text-xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.viewVehicle}</Link>
@@ -15,6 +15,9 @@ interface VehicleDetail {
features: string[]
dailyRate: number
photos: string[]
availability: boolean
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt: string | null
company: {
brand: {
displayName: string
@@ -38,6 +41,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
perDay: '/ day',
continueTo: 'Continue to',
bookingSite: 'booking site',
availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
},
fr: {
unavailable: 'Marketplace indisponible',
@@ -49,6 +54,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
perDay: '/ jour',
continueTo: 'Continuer vers le site de réservation de',
bookingSite: '',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
},
ar: {
unavailable: 'السوق غير متاح',
@@ -60,6 +67,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
perDay: '/ يوم',
continueTo: 'الانتقال إلى موقع حجز',
bookingSite: '',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
},
}[language]
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
@@ -82,6 +91,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
}
const destination = `https://${params.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`
const availabilityDate = vehicle.nextAvailableAt
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
: null
return (
<main className="min-h-screen bg-stone-50 py-10">
@@ -102,6 +114,11 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
<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>
)}
<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>
+20
View File
@@ -12,6 +12,8 @@ interface Vehicle {
dailyRate: number
photos: string[]
availability?: boolean | null
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt?: string | null
company: {
slug: string
brand: {
@@ -72,8 +74,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
rentalCompany: 'Rental company',
checkAvailability: 'Check availability',
available: 'Available',
reserved: 'Reserved',
maintenance: 'In maintenance',
unavailableStatus: 'Unavailable',
from: 'From',
bookNow: 'Reserve',
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',
@@ -123,8 +131,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
rentalCompany: 'Société de location',
checkAvailability: 'Vérifier la disponibilité',
available: 'Disponible',
reserved: 'Réservé',
maintenance: 'En maintenance',
unavailableStatus: 'Indisponible',
from: 'À partir de',
bookNow: 'Réserver',
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',
@@ -173,8 +187,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
rentalCompany: 'شركة تأجير',
checkAvailability: 'تحقق من التوفر',
available: 'متاح',
reserved: 'محجوز',
maintenance: 'قيد الصيانة',
unavailableStatus: 'غير متاح',
from: 'ابتداءً من',
bookNow: 'احجز',
availableFrom: 'متاح ابتداءً من',
reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
details: 'التفاصيل',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة',
@@ -1,5 +1,14 @@
import WorkspaceFrame from '@/components/WorkspaceFrame'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function PlatformOperationsPage() {
return <WorkspaceFrame target="admin" />
const requestHeaders = headers()
const adminUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${adminUrl}/dashboard`)
}
@@ -3,6 +3,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
type Currency = 'MAD' | 'USD' | 'EUR'
type Billing = 'monthly' | 'annual'
@@ -124,7 +125,7 @@ export default function PricingClient() {
const dict = copy[language]
const [currency, setCurrency] = useState<Currency>('MAD')
const [billing, setBilling] = useState<Billing>('monthly')
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
const savings = annualSavingsPct('STARTER', currency) // same % for all plans
@@ -1,6 +1,13 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function RenterSignInPage() {
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
const requestHeaders = headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/sign-in`)
}
@@ -1,6 +1,13 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function RenterSignUpPage() {
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
const requestHeaders = headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/sign-in`)
}