fixing platform admin

This commit is contained in:
root
2026-05-06 22:58:23 -04:00
parent 695a7f7cc7
commit 750ae56a29
175 changed files with 31249 additions and 328 deletions
+90
View File
@@ -0,0 +1,90 @@
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
interface BrandResponse {
company: { slug: string; name: string }
brand: {
displayName: string
tagline: string | null
heroImageUrl: string | null
publicPhone: string | null
publicEmail: string | null
publicCity: string | null
publicCountry: string | null
publicAddress: string | null
whatsappNumber: string | null
} | null
}
export default async function AboutPage() {
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
const companyName = brand?.brand?.displayName ?? brand?.company.name ?? dict.rentalCompanyFallback
const location = [brand?.brand?.publicCity, brand?.brand?.publicCountry].filter(Boolean).join(', ')
return (
<main className="min-h-screen bg-slate-50 py-16">
<div className="shell space-y-10">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.about.eyebrow}</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{companyName}</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
{brand?.brand?.tagline ?? dict.about.defaultTagline}
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<InfoCard label={dict.about.location} value={location || dict.about.locationMissing} />
<InfoCard label={dict.about.phone} value={brand?.brand?.publicPhone || dict.about.phoneMissing} />
<InfoCard label={dict.about.email} value={brand?.brand?.publicEmail || dict.about.emailMissing} />
<InfoCard label={dict.about.whatsapp} value={brand?.brand?.whatsappNumber || dict.about.whatsappMissing} />
</div>
</div>
<div className="card overflow-hidden bg-slate-100 min-h-80">
{brand?.brand?.heroImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.brand.heroImageUrl} alt={companyName} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center p-8 text-center text-slate-500">
{dict.about.heroImageMissing}
</div>
)}
</div>
</section>
<section className="grid gap-6 md:grid-cols-3">
<article className="card p-6">
<h2 className="text-xl font-black text-slate-900">{dict.about.directBookingTitle}</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.directBookingBody}</p>
</article>
<article className="card p-6">
<h2 className="text-xl font-black text-slate-900">{dict.about.publishedFleetTitle}</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.publishedFleetBody}</p>
</article>
<article className="card p-6">
<h2 className="text-xl font-black text-slate-900">{dict.about.marketplaceReadyTitle}</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.marketplaceReadyBody}</p>
</article>
</section>
<section className="card p-8">
<h2 className="text-2xl font-black text-slate-900">{dict.about.companyDetails}</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">
{brand?.brand?.publicAddress ?? dict.about.companyAddressMissing}
</p>
</section>
</div>
</main>
)
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="card p-5">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">{label}</p>
<p className="mt-2 text-sm font-medium text-slate-900">{value}</p>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
export default function BlogPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#fff, #f8fafc_35%)] py-16">
<div className="shell space-y-10">
<section className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.blog.eyebrow}</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.blog.title}</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
{dict.blog.description}
</p>
</section>
<section className="grid gap-6 lg:grid-cols-3">
{dict.blog.posts.map((post) => (
<article key={post.title} className="card p-7">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700">{post.category}</p>
<h2 className="mt-4 text-2xl font-black tracking-tight text-slate-900">{post.title}</h2>
<p className="mt-4 text-sm leading-7 text-slate-600">{post.excerpt}</p>
<p className="mt-6 text-sm font-semibold text-slate-900">{dict.blog.draftPlaceholder}</p>
</article>
))}
</section>
</div>
</main>
)
}
@@ -0,0 +1,754 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { DEFAULT_COMPANY_SLUG } from '@/lib/api'
import { getPublicSiteDictionary, type PublicSiteLanguage } from '@/lib/i18n'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
seats: number
transmission: string
photos: string[]
}
interface PromoOffer {
id: string
title: string
discountValue: number
}
interface InsurancePolicy {
id: string
name: string
type: string
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL'
chargeValue: number
isRequired: boolean
}
interface ContractSettings {
fuelPolicyType: string
fuelPolicyNote: string | null
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
additionalDriverDailyRate: number
additionalDriverFlatRate: number
}
interface BookingOptions {
insurancePolicies: InsurancePolicy[]
contractSettings: ContractSettings | null
}
interface Step1 {
startDate: string
endDate: string
}
interface Step2 {
firstName: string
lastName: string
email: string
phone: string
driverLicense: string
dateOfBirth: string
licenseExpiry: string
licenseIssuedAt: string
nationality: string
promoCode: string
}
interface AdditionalDriverForm {
firstName: string
lastName: string
email: string
phone: string
driverLicense: string
dateOfBirth: string
licenseExpiry: string
licenseIssuedAt: string
nationality: string
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
function diffDays(start: string, end: string): number {
if (!start || !end) return 0
const ms = new Date(end).getTime() - new Date(start).getTime()
return Math.max(1, Math.ceil(ms / 86400000))
}
function fmt(n: number) {
return n.toLocaleString('fr-MA', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 })
}
function inputClass(error?: string) {
return [
'w-full rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500',
error ? 'border-red-400 bg-red-50' : 'border-slate-300 bg-white',
].join(' ')
}
function insuranceCharge(policy: InsurancePolicy, days: number, baseTotal: number) {
if (policy.chargeType === 'PER_DAY') return policy.chargeValue * days
if (policy.chargeType === 'PERCENTAGE_OF_RENTAL') return Math.round(baseTotal * policy.chargeValue / 100)
return policy.chargeValue
}
function additionalDriverCharge(settings: ContractSettings | null, count: number, days: number) {
if (!settings || count === 0) return 0
if (settings.additionalDriverCharge === 'PER_DAY') return settings.additionalDriverDailyRate * count * days
if (settings.additionalDriverCharge === 'FLAT') return settings.additionalDriverFlatRate * count
return 0
}
function getDayLabel(language: PublicSiteLanguage, days: number) {
if (language === 'fr') return days === 1 ? 'jour' : 'jours'
if (language === 'ar') return days === 1 ? 'يوم' : 'أيام'
return days === 1 ? 'day' : 'days'
}
function StepDots({
step,
steps,
}: {
step: number
steps: {
selectDates: string
driverDetails: string
reviewBooking: string
payment: string
}
}) {
return (
<div className="mb-6 flex items-center gap-2">
{[1, 2, 3, 4].map((n) => (
<div key={n} className="flex items-center gap-2">
<div
className={[
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold',
n === step ? 'bg-slate-900 text-white' : n < step ? 'bg-sky-600 text-white' : 'bg-slate-200 text-slate-500',
].join(' ')}
>
{n < step ? '✓' : n}
</div>
{n < 4 && <div className={['h-0.5 w-8 rounded', n < step ? 'bg-sky-600' : 'bg-slate-200'].join(' ')} />}
</div>
))}
<span className="ml-2 text-sm text-slate-500">
{step === 1 && steps.selectDates}
{step === 2 && steps.driverDetails}
{step === 3 && steps.reviewBooking}
{step === 4 && steps.payment}
</span>
</div>
)
}
function SummaryRow({
label,
value,
valueClass = 'text-slate-900',
}: {
label: string
value: string
valueClass?: string
}) {
return (
<div className="flex justify-between gap-4 px-4 py-3">
<span className="flex-shrink-0 text-slate-500">{label}</span>
<span className={`text-right font-medium ${valueClass}`}>{value}</span>
</div>
)
}
export default function BookClient({ language }: { language: PublicSiteLanguage }) {
const dict = getPublicSiteDictionary(language)
const params = useSearchParams()
const router = useRouter()
const vehicleId = params.get('vehicleId') ?? ''
const offerId = params.get('offerId') ?? undefined
const ref = params.get('ref') ?? 'direct'
const slug = params.get('slug') ?? DEFAULT_COMPANY_SLUG
const [step, setStep] = useState(1)
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [vehicleLoading, setVehicleLoading] = useState(!!vehicleId)
const [vehicleError, setVehicleError] = useState('')
const [bookingOptions, setBookingOptions] = useState<BookingOptions | null>(null)
const [bookingOptionsError, setBookingOptionsError] = useState('')
const [step1, setStep1] = useState<Step1>({ startDate: '', endDate: '' })
const [step1Errors, setStep1Errors] = useState<Partial<Step1>>({})
const [step2, setStep2] = useState<Step2>({
firstName: '',
lastName: '',
email: '',
phone: '',
driverLicense: '',
dateOfBirth: '',
licenseExpiry: '',
licenseIssuedAt: '',
nationality: '',
promoCode: '',
})
const [step2Errors, setStep2Errors] = useState<Record<string, string>>({})
const [additionalDriverEnabled, setAdditionalDriverEnabled] = useState(false)
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: '',
lastName: '',
email: '',
phone: '',
driverLicense: '',
dateOfBirth: '',
licenseExpiry: '',
licenseIssuedAt: '',
nationality: '',
})
const [selectedInsurancePolicyIds, setSelectedInsurancePolicyIds] = useState<string[]>([])
const [promoApplied, setPromoApplied] = useState<PromoOffer | null>(null)
const [promoLoading, setPromoLoading] = useState(false)
const [promoError, setPromoError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState('')
const [reservationId, setReservationId] = useState<string | null>(null)
const [requiresManualApproval, setRequiresManualApproval] = useState(false)
const [paymentProvider, setPaymentProvider] = useState<'AMANPAY' | 'PAYPAL' | 'CASH'>('AMANPAY')
const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD')
const [paymentLoading, setPaymentLoading] = useState(false)
const [paymentError, setPaymentError] = useState('')
useEffect(() => {
if (!vehicleId) return
setVehicleLoading(true)
fetch(`${API_BASE}/site/${slug}/vehicles/${vehicleId}`)
.then((r) => r.json())
.then((j) => {
if (j?.data) setVehicle(j.data as VehicleDetail)
else setVehicleError(dict.booking.vehicleNotFound)
})
.catch(() => setVehicleError(dict.booking.vehicleLoadError))
.finally(() => setVehicleLoading(false))
}, [dict.booking.vehicleLoadError, dict.booking.vehicleNotFound, vehicleId, slug])
useEffect(() => {
fetch(`${API_BASE}/site/${slug}/booking-options`)
.then((r) => r.json())
.then((j) => setBookingOptions(j.data as BookingOptions))
.catch(() => setBookingOptionsError(dict.booking.bookingOptionsLoadError))
}, [dict.booking.bookingOptionsLoadError, slug])
const days = diffDays(step1.startDate, step1.endDate)
const baseTotal = vehicle ? vehicle.dailyRate * days : 0
const discount = promoApplied ? Math.round(baseTotal * (promoApplied.discountValue / 100)) : 0
const requiredPolicies = (bookingOptions?.insurancePolicies ?? []).filter((policy) => policy.isRequired)
const optionalPolicies = (bookingOptions?.insurancePolicies ?? []).filter((policy) => !policy.isRequired)
const selectedPolicies = [
...requiredPolicies,
...optionalPolicies.filter((policy) => selectedInsurancePolicyIds.includes(policy.id)),
]
const insuranceTotal = selectedPolicies.reduce((sum, policy) => sum + insuranceCharge(policy, days, baseTotal), 0)
const additionalDriverTotal = additionalDriverCharge(bookingOptions?.contractSettings ?? null, additionalDriverEnabled ? 1 : 0, days)
const grandTotal = baseTotal - discount + insuranceTotal + additionalDriverTotal
const dayLabel = getDayLabel(language, days)
function validateStep1() {
const errs: Partial<Step1> = {}
if (!step1.startDate) errs.startDate = dict.booking.validation.pickStartDate
if (!step1.endDate) errs.endDate = dict.booking.validation.pickEndDate
if (step1.startDate && step1.endDate && new Date(step1.endDate) <= new Date(step1.startDate)) errs.endDate = dict.booking.validation.endDateAfterStart
setStep1Errors(errs)
return Object.keys(errs).length === 0
}
function validateStep2() {
const errs: Record<string, string> = {}
if (!step2.firstName.trim()) errs.firstName = dict.booking.validation.firstNameRequired
if (!step2.lastName.trim()) errs.lastName = dict.booking.validation.lastNameRequired
if (!step2.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(step2.email)) errs.email = dict.booking.validation.validEmailRequired
if (!step2.driverLicense.trim()) errs.driverLicense = dict.booking.validation.driverLicenseRequired
if (!step2.dateOfBirth) errs.dateOfBirth = dict.booking.validation.dateOfBirthRequired
if (!step2.licenseExpiry) errs.licenseExpiry = dict.booking.validation.licenseExpiryRequired
if (!step2.licenseIssuedAt) errs.licenseIssuedAt = dict.booking.validation.licenseIssueDateRequired
if (additionalDriverEnabled) {
if (!additionalDriver.firstName.trim()) errs.additionalDriverFirstName = dict.booking.validation.additionalDriverFirstNameRequired
if (!additionalDriver.lastName.trim()) errs.additionalDriverLastName = dict.booking.validation.additionalDriverLastNameRequired
if (!additionalDriver.driverLicense.trim()) errs.additionalDriverLicense = dict.booking.validation.additionalDriverLicenseRequired
}
setStep2Errors(errs)
return Object.keys(errs).length === 0
}
async function applyPromo() {
if (!step2.promoCode.trim()) return
setPromoLoading(true)
setPromoError('')
setPromoApplied(null)
try {
const res = await fetch(`${API_BASE}/site/${slug}/book/validate-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: step2.promoCode.trim() }),
})
const json = await res.json()
if (!res.ok) setPromoError(json?.message ?? dict.booking.promo.invalid)
else setPromoApplied(json.data as PromoOffer)
} catch {
setPromoError(dict.booking.promo.unavailable)
} finally {
setPromoLoading(false)
}
}
async function handleSubmit() {
setSubmitting(true)
setSubmitError('')
try {
const res = await fetch(`${API_BASE}/site/${slug}/book`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vehicleId,
offerId: promoApplied?.id ?? offerId,
promoCodeUsed: promoApplied ? step2.promoCode.trim() : undefined,
source: ref === 'marketplace' ? 'MARKETPLACE' : 'PUBLIC_SITE',
startDate: new Date(step1.startDate).toISOString(),
endDate: new Date(step1.endDate).toISOString(),
firstName: step2.firstName.trim(),
lastName: step2.lastName.trim(),
email: step2.email.trim(),
phone: step2.phone.trim() || undefined,
driverLicense: step2.driverLicense.trim(),
dateOfBirth: new Date(step2.dateOfBirth).toISOString(),
licenseExpiry: new Date(step2.licenseExpiry).toISOString(),
licenseIssuedAt: new Date(step2.licenseIssuedAt).toISOString(),
nationality: step2.nationality.trim() || undefined,
selectedInsurancePolicyIds,
additionalDrivers: additionalDriverEnabled ? [{
firstName: additionalDriver.firstName.trim(),
lastName: additionalDriver.lastName.trim(),
email: additionalDriver.email.trim() || undefined,
phone: additionalDriver.phone.trim() || undefined,
driverLicense: additionalDriver.driverLicense.trim(),
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
nationality: additionalDriver.nationality.trim() || undefined,
}] : [],
}),
})
const json = await res.json()
if (!res.ok) {
setSubmitError(json?.message ?? dict.booking.submit.failed)
return
}
setReservationId(json.data.id)
setRequiresManualApproval(Boolean(json.data.requiresManualApproval))
setStep(4)
if (json.data.requiresManualApproval) setPaymentProvider('CASH')
} catch {
setSubmitError(dict.booking.submit.network)
} finally {
setSubmitting(false)
}
}
async function handlePayOnline() {
if (!reservationId) return
setPaymentLoading(true)
setPaymentError('')
try {
const base = window.location.origin
const res = await fetch(`${API_BASE}/site/${slug}/booking/${reservationId}/pay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: paymentProvider,
currency: paymentCurrency,
successUrl: `${base}/book/confirmation?id=${reservationId}&payment=success`,
failureUrl: `${base}/book?vehicleId=${vehicleId}&payment=failed`,
}),
})
const json = await res.json()
if (!res.ok) {
setPaymentError(json?.message ?? dict.booking.payment.initiateFailed)
return
}
window.location.href = json.data.checkoutUrl
} catch {
setPaymentError(dict.booking.payment.network)
} finally {
setPaymentLoading(false)
}
}
function handlePayAtPickup() {
if (!reservationId) return
router.push(`/book/confirmation?id=${reservationId}`)
}
return (
<div className="card p-8">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.booking.eyebrow}</p>
<h1 className="mt-2 text-3xl font-black text-slate-900">{dict.booking.title}</h1>
<div className="mt-6">
<StepDots step={step} steps={dict.booking.steps} />
</div>
{vehicleLoading && <div className="mb-6 rounded-xl bg-slate-100 px-4 py-3 text-sm text-slate-500">{dict.booking.loadingVehicle}</div>}
{vehicleError && <div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{vehicleError}</div>}
{vehicle && (
<div className="mb-6 flex items-center gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
{vehicle.photos[0] && (
// eslint-disable-next-line @next/next/no-img-element
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-16 w-24 rounded-lg object-cover flex-shrink-0" />
)}
<div>
<p className="font-bold text-slate-900">
{vehicle.make} {vehicle.model} <span className="font-normal text-slate-500">({vehicle.year})</span>
</p>
<p className="text-sm text-slate-500">{vehicle.category} · {vehicle.transmission} · {dict.vehicles.seats(vehicle.seats)}</p>
<p className="mt-0.5 text-sm font-semibold text-slate-800">{fmt(vehicle.dailyRate)} {dict.vehicles.detail.perDay}</p>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.startDate}</label>
<input type="date" value={step1.startDate} min={new Date().toISOString().slice(0, 10)} onChange={(e) => setStep1((s) => ({ ...s, startDate: e.target.value }))} className={inputClass(step1Errors.startDate)} />
{step1Errors.startDate && <p className="mt-1 text-xs text-red-600">{step1Errors.startDate}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.endDate}</label>
<input type="date" value={step1.endDate} min={step1.startDate || new Date().toISOString().slice(0, 10)} onChange={(e) => setStep1((s) => ({ ...s, endDate: e.target.value }))} className={inputClass(step1Errors.endDate)} />
{step1Errors.endDate && <p className="mt-1 text-xs text-red-600">{step1Errors.endDate}</p>}
</div>
</div>
{days > 0 && vehicle && (
<div className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">
<span className="font-semibold">{days} {dayLabel}</span> × {fmt(vehicle.dailyRate)} = <span className="font-bold">{fmt(baseTotal)}</span>
</div>
)}
<button onClick={() => validateStep1() && setStep(2)} className="w-full rounded-full bg-slate-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700">
{dict.booking.continueToDriverDetails}
</button>
</div>
)}
{step === 2 && (
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.firstName}</label>
<input value={step2.firstName} onChange={(e) => setStep2((s) => ({ ...s, firstName: e.target.value }))} placeholder={dict.booking.firstName} className={inputClass(step2Errors.firstName)} />
{step2Errors.firstName && <p className="mt-1 text-xs text-red-600">{step2Errors.firstName}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.lastName}</label>
<input value={step2.lastName} onChange={(e) => setStep2((s) => ({ ...s, lastName: e.target.value }))} placeholder={dict.booking.lastName} className={inputClass(step2Errors.lastName)} />
{step2Errors.lastName && <p className="mt-1 text-xs text-red-600">{step2Errors.lastName}</p>}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.email}</label>
<input type="email" value={step2.email} onChange={(e) => setStep2((s) => ({ ...s, email: e.target.value }))} placeholder="you@example.com" className={inputClass(step2Errors.email)} />
{step2Errors.email && <p className="mt-1 text-xs text-red-600">{step2Errors.email}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.phone}</label>
<input value={step2.phone} onChange={(e) => setStep2((s) => ({ ...s, phone: e.target.value }))} placeholder="+212 6xx xxx xxx" className={inputClass()} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.driverLicense}</label>
<input value={step2.driverLicense} onChange={(e) => setStep2((s) => ({ ...s, driverLicense: e.target.value }))} placeholder={dict.booking.driverLicense} className={inputClass(step2Errors.driverLicense)} />
{step2Errors.driverLicense && <p className="mt-1 text-xs text-red-600">{step2Errors.driverLicense}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.nationality}</label>
<input value={step2.nationality} onChange={(e) => setStep2((s) => ({ ...s, nationality: e.target.value }))} placeholder={dict.booking.nationality} className={inputClass()} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.dateOfBirth}</label>
<input type="date" value={step2.dateOfBirth} onChange={(e) => setStep2((s) => ({ ...s, dateOfBirth: e.target.value }))} className={inputClass(step2Errors.dateOfBirth)} />
{step2Errors.dateOfBirth && <p className="mt-1 text-xs text-red-600">{step2Errors.dateOfBirth}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.licenseIssued}</label>
<input type="date" value={step2.licenseIssuedAt} onChange={(e) => setStep2((s) => ({ ...s, licenseIssuedAt: e.target.value }))} className={inputClass(step2Errors.licenseIssuedAt)} />
{step2Errors.licenseIssuedAt && <p className="mt-1 text-xs text-red-600">{step2Errors.licenseIssuedAt}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.licenseExpiry}</label>
<input type="date" value={step2.licenseExpiry} onChange={(e) => setStep2((s) => ({ ...s, licenseExpiry: e.target.value }))} className={inputClass(step2Errors.licenseExpiry)} />
{step2Errors.licenseExpiry && <p className="mt-1 text-xs text-red-600">{step2Errors.licenseExpiry}</p>}
</div>
</div>
{bookingOptionsError && <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{bookingOptionsError}</div>}
{bookingOptions && (
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<p className="text-sm font-semibold text-slate-900">{dict.booking.insuranceAndPickupPolicies}</p>
<p className="mt-1 text-xs text-slate-500">
{bookingOptions.contractSettings?.fuelPolicyNote || dict.booking.insuranceHint}
</p>
</div>
{selectedPolicies.length > 0 && (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-600">
{dict.booking.requiredCoverage}: {requiredPolicies.map((policy) => policy.name).join(', ') || dict.booking.none}
</div>
)}
{optionalPolicies.length > 0 ? (
<div className="space-y-2">
{optionalPolicies.map((policy) => {
const selected = selectedInsurancePolicyIds.includes(policy.id)
return (
<label key={policy.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-900">{policy.name}</p>
<p className="text-xs text-slate-500">{policy.type}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-slate-800">{fmt(insuranceCharge(policy, days, baseTotal))}</span>
<input
type="checkbox"
checked={selected}
onChange={(e) => setSelectedInsurancePolicyIds((current) => e.target.checked ? [...current, policy.id] : current.filter((id) => id !== policy.id))}
/>
</div>
</label>
)
})}
</div>
) : (
<p className="text-xs text-slate-500">{dict.booking.noOptionalInsurance}</p>
)}
<div className="rounded-xl border border-slate-200 bg-white p-4">
<label className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{dict.booking.additionalDriver}</p>
<p className="text-xs text-slate-500">{dict.booking.charge}: {fmt(additionalDriverCharge(bookingOptions.contractSettings, 1, days))}</p>
</div>
<input type="checkbox" checked={additionalDriverEnabled} onChange={(e) => setAdditionalDriverEnabled(e.target.checked)} />
</label>
{additionalDriverEnabled && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<input value={additionalDriver.firstName} onChange={(e) => setAdditionalDriver((current) => ({ ...current, firstName: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.firstName.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverFirstName)} />
{step2Errors.additionalDriverFirstName && <p className="mt-1 text-xs text-red-600">{step2Errors.additionalDriverFirstName}</p>}
</div>
<div>
<input value={additionalDriver.lastName} onChange={(e) => setAdditionalDriver((current) => ({ ...current, lastName: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.lastName.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverLastName)} />
{step2Errors.additionalDriverLastName && <p className="mt-1 text-xs text-red-600">{step2Errors.additionalDriverLastName}</p>}
</div>
<input value={additionalDriver.email} onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.email.toLowerCase()}`} className={inputClass()} />
<input value={additionalDriver.phone} onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.phone.toLowerCase()}`} className={inputClass()} />
<div>
<input value={additionalDriver.driverLicense} onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.driverLicense.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverLicense)} />
{step2Errors.additionalDriverLicense && <p className="mt-1 text-xs text-red-600">{step2Errors.additionalDriverLicense}</p>}
</div>
<input value={additionalDriver.nationality} onChange={(e) => setAdditionalDriver((current) => ({ ...current, nationality: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.nationality.toLowerCase()}`} className={inputClass()} />
<input type="date" value={additionalDriver.dateOfBirth} onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))} className={inputClass()} />
<input type="date" value={additionalDriver.licenseIssuedAt} onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))} className={inputClass()} />
<input type="date" value={additionalDriver.licenseExpiry} onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))} className={inputClass()} />
</div>
)}
</div>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.promoCode}</label>
<div className="flex gap-2">
<input
value={step2.promoCode}
onChange={(e) => {
setStep2((s) => ({ ...s, promoCode: e.target.value }))
setPromoApplied(null)
setPromoError('')
}}
placeholder={dict.booking.enterCode}
className={`flex-1 ${inputClass(promoError || undefined)}`}
/>
<button type="button" onClick={applyPromo} disabled={promoLoading || !step2.promoCode.trim()} className="rounded-xl border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-50">
{promoLoading ? '...' : dict.booking.apply}
</button>
</div>
{promoError && <p className="mt-1 text-xs text-red-600">{promoError}</p>}
{promoApplied && <p className="mt-1 text-xs font-medium text-emerald-700">{dict.booking.appliedPromo(promoApplied.title, promoApplied.discountValue)}</p>}
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setStep(1)} className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50">{dict.booking.back}</button>
<button onClick={() => validateStep2() && setStep(3)} className="flex-1 rounded-full bg-slate-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700">
{dict.booking.reviewBookingCta}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-6">
<div className="overflow-hidden rounded-xl border border-slate-200 divide-y divide-slate-100 text-sm">
<SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${vehicle.make} ${vehicle.model}` : vehicleId} />
<SummaryRow label={dict.booking.summary.dates} value={`${step1.startDate} -> ${step1.endDate} (${days} ${dayLabel})`} />
<SummaryRow label={dict.booking.summary.primaryDriver} value={`${step2.firstName} ${step2.lastName}`} />
<SummaryRow label={dict.booking.summary.email} value={step2.email} />
<SummaryRow label={dict.booking.summary.driverLicense} value={step2.driverLicense} />
<SummaryRow label={dict.booking.summary.licenseExpiry} value={step2.licenseExpiry} />
{additionalDriverEnabled && <SummaryRow label={dict.booking.summary.additionalDriver} value={`${additionalDriver.firstName} ${additionalDriver.lastName}`} />}
{promoApplied && <SummaryRow label={dict.booking.summary.promoCode} value={`${step2.promoCode} (-${promoApplied.discountValue}%)`} valueClass="font-semibold text-emerald-700" />}
</div>
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm">
<div className="flex justify-between text-slate-600">
<span>{days} {dayLabel} × {fmt(vehicle?.dailyRate ?? 0)}</span>
<span>{fmt(baseTotal)}</span>
</div>
{discount > 0 && (
<div className="flex justify-between text-emerald-700">
<span>{dict.booking.summary.discount(promoApplied?.discountValue ?? 0)}</span>
<span>-{fmt(discount)}</span>
</div>
)}
{selectedPolicies.map((policy) => (
<div key={policy.id} className="flex justify-between text-slate-600">
<span>{policy.name}</span>
<span>{fmt(insuranceCharge(policy, days, baseTotal))}</span>
</div>
))}
{additionalDriverEnabled && (
<div className="flex justify-between text-slate-600">
<span>{dict.booking.additionalDriver}</span>
<span>{fmt(additionalDriverTotal)}</span>
</div>
)}
<div className="flex justify-between border-t border-slate-200 pt-2 text-base font-bold text-slate-900">
<span>{dict.booking.summary.total}</span>
<span>{fmt(grandTotal)}</span>
</div>
</div>
{submitError && <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{submitError}</div>}
<div className="flex gap-3">
<button onClick={() => setStep(2)} className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50">{dict.booking.back}</button>
<button onClick={handleSubmit} disabled={submitting} className="flex-1 rounded-full bg-slate-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700 disabled:opacity-60">
{submitting ? dict.booking.processing : dict.booking.confirmReservation}
</button>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-6">
<div className="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-800">
{dict.booking.reservationCreated}
</div>
{requiresManualApproval && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{dict.booking.manualApprovalRequired}
</div>
)}
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm">
<span className="text-slate-600">{dict.booking.totalDue}</span>
<span className="text-base font-black text-slate-900">{fmt(grandTotal)}</span>
</div>
<div>
<p className="mb-2 text-sm font-medium text-slate-700">{dict.booking.currency}</p>
<div className="flex gap-2">
{(['MAD', 'USD', 'EUR'] as const).map((currency) => (
<button
key={currency}
onClick={() => setPaymentCurrency(currency)}
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors ${
paymentCurrency === currency ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{currency}
</button>
))}
</div>
</div>
<div>
<p className="mb-3 text-sm font-medium text-slate-700">{dict.booking.paymentMethod}</p>
<div className="space-y-2">
{(['AMANPAY', 'PAYPAL', 'CASH'] as const).map((provider) => {
const disabled = requiresManualApproval && provider !== 'CASH'
return (
<button
key={provider}
onClick={() => !disabled && setPaymentProvider(provider)}
className={`w-full rounded-xl border-2 p-4 text-left transition-all ${
paymentProvider === provider ? 'border-slate-900 bg-slate-50' : 'border-slate-200 hover:border-slate-300'
} ${disabled ? 'opacity-50' : ''}`}
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-100 text-lg">
{provider === 'AMANPAY' ? '🏦' : provider === 'PAYPAL' ? '🔵' : '💵'}
</div>
<div>
<p className="text-sm font-semibold text-slate-900">
{provider === 'AMANPAY' ? dict.booking.paymentMethods.amanpay.title : provider === 'PAYPAL' ? dict.booking.paymentMethods.paypal.title : dict.booking.paymentMethods.cash.title}
</p>
<p className="mt-0.5 text-xs text-slate-500">
{provider === 'AMANPAY' && dict.booking.paymentMethods.amanpay.description}
{provider === 'PAYPAL' && dict.booking.paymentMethods.paypal.description}
{provider === 'CASH' && dict.booking.paymentMethods.cash.description}
</p>
</div>
</div>
</button>
)
})}
</div>
</div>
{paymentError && <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{paymentError}</div>}
<button
onClick={paymentProvider === 'CASH' ? handlePayAtPickup : handlePayOnline}
disabled={paymentLoading || (requiresManualApproval && paymentProvider !== 'CASH')}
className="w-full rounded-full bg-slate-900 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 disabled:opacity-60"
>
{paymentLoading
? dict.booking.redirectingToPayment
: paymentProvider === 'CASH'
? dict.booking.confirmPayAtPickup
: dict.booking.payWith(paymentProvider === 'AMANPAY' ? dict.booking.paymentMethods.amanpay.title : dict.booking.paymentMethods.paypal.title)}
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,124 @@
import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
interface ReservationDetail {
id: string
status: string
startDate: string
endDate: string
totalDays: number
totalAmount: number
vehicle: { make: string; model: string; year: number } | null
customer: { firstName: string; lastName: string; email: string } | null
}
interface PageProps {
searchParams?: { id?: string }
}
export const dynamic = 'force-dynamic'
export default async function BookingConfirmationPage({ searchParams = {} }: PageProps) {
const reservationId = searchParams.id
const slug = DEFAULT_COMPANY_SLUG
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const reservation = reservationId
? await siteFetchOrDefault<ReservationDetail | null>(
`/site/${slug}/booking/${reservationId}`,
null,
)
: null
return (
<main className="min-h-screen bg-slate-50 py-16">
<div className="shell max-w-2xl">
<div className="card p-10 text-center">
<div className="mx-auto mb-5 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>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-700">{dict.confirmation.eyebrow}</p>
<h1 className="mt-3 text-4xl font-black text-slate-900">{dict.confirmation.title}</h1>
<p className="mt-4 text-slate-600">
{dict.confirmation.description}
</p>
{reservation && (
<div className="mt-8 rounded-xl border border-slate-200 text-left divide-y divide-slate-100 overflow-hidden text-sm">
<div className="bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
{dict.confirmation.bookingSummary}
</div>
{reservation.vehicle && (
<Row label={dict.confirmation.vehicle} value={`${reservation.vehicle.make} ${reservation.vehicle.model} (${reservation.vehicle.year})`} />
)}
{reservation.customer && (
<Row label={dict.confirmation.customer} value={`${reservation.customer.firstName} ${reservation.customer.lastName}`} />
)}
{reservation.customer?.email && (
<Row label={dict.confirmation.email} value={reservation.customer.email} />
)}
<Row
label={dict.confirmation.dates}
value={`${new Date(reservation.startDate).toLocaleDateString()} -> ${new Date(reservation.endDate).toLocaleDateString()} (${reservation.totalDays})`}
/>
<Row
label={dict.confirmation.total}
value={`${reservation.totalAmount.toLocaleString('fr-MA', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 })}`}
valueClass="font-bold text-slate-900"
/>
<Row label={dict.confirmation.reference} value={reservation.id} valueClass="font-mono text-xs text-slate-500 break-all" />
<Row
label={dict.confirmation.status}
value={reservation.status}
valueClass="rounded-full bg-amber-100 text-amber-800 px-2 py-0.5 text-xs font-semibold uppercase"
/>
</div>
)}
{!reservation && reservationId && (
<p className="mt-6 text-sm text-slate-500">
{dict.confirmation.reference}: <span className="font-mono">{reservationId}</span>
</p>
)}
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link
href="/vehicles"
className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white"
>
{dict.confirmation.browseMoreVehicles}
</Link>
<Link
href="/"
className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700"
>
{dict.confirmation.backHome}
</Link>
</div>
</div>
</div>
</main>
)
}
function Row({
label,
value,
valueClass = 'text-slate-700 font-medium',
}: {
label: string
value: string
valueClass?: string
}) {
return (
<div className="flex justify-between gap-4 px-4 py-3">
<span className="flex-shrink-0 text-slate-500">{label}</span>
<span className={`text-right ${valueClass}`}>{value}</span>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { Suspense } from 'react'
import BookClient from './BookClient'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
export const dynamic = 'force-dynamic'
export default function BookPage() {
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell max-w-3xl">
<Suspense fallback={<div className="card p-8 text-sm text-slate-500">{dict.booking.loadingForm}</div>}>
<BookClient language={language} />
</Suspense>
</div>
</main>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
export default function ContactPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
return (
<main className="min-h-screen bg-slate-50 py-16">
<div className="shell max-w-2xl">
<div className="card p-8">
<h1 className="text-3xl font-black text-slate-900">{dict.contact.title}</h1>
<p className="mt-3 text-slate-600">{dict.contact.description}</p>
</div>
</div>
</main>
)
}
+15
View File
@@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-white text-slate-900 antialiased;
}
.shell {
@apply mx-auto max-w-6xl px-4 sm:px-6 lg:px-8;
}
.card {
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
}
+101
View File
@@ -0,0 +1,101 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
import PublicLanguageSwitcher from '@/components/PublicLanguageSwitcher'
export const metadata: Metadata = {
title: 'RentalDriveGo Public Site',
description: 'Branded company booking site for RentalDriveGo tenants.',
}
interface BrandResponse {
company: { slug: string; name: string }
brand: { displayName: string; tagline: string | null; heroImageUrl: string | null } | null
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const slug = DEFAULT_COMPANY_SLUG
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const brand = await siteFetchOrDefault<BrandResponse | null>(
`/site/${slug}/brand`,
null,
)
const companyName = brand?.brand?.displayName ?? brand?.company.name ?? 'RentalDriveGo'
return (
<html lang={dict.lang} dir={dict.dir}>
<body>
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur-sm">
<div className="shell flex h-14 items-center justify-between gap-6">
<Link
href="/"
className="flex items-center gap-2 font-black text-slate-900 tracking-tight text-lg"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-sky-600 text-white text-xs font-black select-none">
{companyName.charAt(0).toUpperCase()}
</span>
<span className="hidden sm:inline">{companyName}</span>
</Link>
<nav className="flex items-center gap-1">
<NavLink href="/about">{dict.nav.about}</NavLink>
<NavLink href="/vehicles">{dict.nav.vehicles}</NavLink>
<NavLink href="/offers">{dict.nav.offers}</NavLink>
<NavLink href="/pricing">{dict.nav.pricing}</NavLink>
<NavLink href="/contact">{dict.nav.contact}</NavLink>
</nav>
<div className="flex items-center gap-3">
<PublicLanguageSwitcher currentLanguage={language} />
<Link
href="/vehicles"
className="hidden rounded-full bg-slate-900 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 sm:inline-flex"
>
{dict.nav.bookCar}
</Link>
</div>
</div>
</header>
{children}
<footer className="mt-16 border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
<div className="shell flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
{dict.nav.siteNavigation}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<nav className="flex flex-wrap items-center justify-center gap-2 text-sm text-stone-500">
<Link href="/about" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.about}</Link>
<Link href="/vehicles" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.vehicles}</Link>
<Link href="/offers" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.offers}</Link>
<Link href="/pricing" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.pricing}</Link>
<Link href="/blog" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.blog}</Link>
<Link href="/contact" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.contact}</Link>
</nav>
<p className="text-sm text-stone-500">
&copy; {new Date().getFullYear()} {companyName}
</p>
</div>
</div>
</footer>
</body>
</html>
)
}
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link
href={href}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
>
{children}
</Link>
)
}
+35
View File
@@ -0,0 +1,35 @@
import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetch } from '@/lib/api'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
interface Offer {
id: string
title: string
description: string | null
discountValue: number
validUntil: string
}
export default async function OffersPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const offers = await siteFetch<Offer[]>(`/site/${DEFAULT_COMPANY_SLUG}/offers`).catch(() => [])
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell">
<h1 className="text-4xl font-black text-slate-900">{dict.offers.title}</h1>
<div className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{offers.map((offer) => (
<div key={offer.id} className="card p-6">
<h2 className="text-xl font-bold text-slate-900">{offer.title}</h2>
<p className="mt-3 text-3xl font-black text-sky-700">{offer.discountValue}%</p>
<p className="mt-3 text-sm text-slate-600">{offer.description ?? dict.offers.noDescription}</p>
<Link href={`/book?offerId=${offer.id}`} className="mt-6 inline-block rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.offers.bookOffer}</Link>
</div>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{dict.offers.empty}</div>}
</div>
</div>
</main>
)
}
+105
View File
@@ -0,0 +1,105 @@
import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
interface BrandResponse {
company: { slug: string; name: string }
brand: { displayName: string; tagline: string | null; heroImageUrl: string | null; publicPhone: string | null } | null
}
interface Vehicle {
id: string
make: string
model: string
dailyRate: number
photos: string[]
category: string
}
interface Offer {
id: string
title: string
discountValue: number
}
export default async function PublicHomePage() {
const slug = DEFAULT_COMPANY_SLUG
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const [brand, vehicles, offers] = await Promise.all([
siteFetch<BrandResponse>(`/site/${slug}/brand`).catch(() => null),
siteFetch<Vehicle[]>(`/site/${slug}/vehicles`).catch(() => []),
siteFetch<Offer[]>(`/site/${slug}/offers`).catch(() => []),
])
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_30%)]">
<div className="shell py-16 space-y-12">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr] items-center">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{brand?.brand?.displayName ?? brand?.company.name ?? dict.siteNameFallback}</p>
<h1 className="mt-5 text-5xl font-black tracking-tight text-slate-900">{brand?.brand?.tagline ?? dict.home.heroTitleFallback}</h1>
<p className="mt-6 text-lg leading-8 text-slate-600">{dict.home.heroDescription}</p>
<div className="mt-10 flex flex-wrap gap-4">
<Link href="/offers" className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{dict.home.viewOffers}</Link>
<Link href="/contact" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{dict.home.contactCompany}</Link>
</div>
</div>
<div className="card overflow-hidden bg-slate-100 min-h-80">
{brand?.brand?.heroImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.brand.heroImageUrl} alt={brand.brand.displayName} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center p-8 text-center text-slate-500">{dict.home.heroImageMissing}</div>
)}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">{dict.home.activeOffers}</h2>
<Link href="/offers" className="text-sm font-semibold text-sky-700">{dict.home.seeAllOffers}</Link>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{offers.map((offer) => (
<div key={offer.id} className="card p-5">
<h3 className="text-lg font-semibold text-slate-900">{offer.title}</h3>
<p className="mt-3 text-3xl font-black text-sky-700">{offer.discountValue}%</p>
</div>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{dict.home.noActiveOffers}</div>}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">{dict.home.publishedVehicles}</h2>
<p className="text-sm text-slate-500">{dict.home.available(vehicles.length)}</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-slate-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">
<h3 className="text-xl font-bold text-slate-900">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 text-sm text-slate-500">{vehicle.category}</p>
<div className="mt-4 flex items-end justify-between">
<p className="text-xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
<Link href={`/vehicles/${vehicle.id}`} className="rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.home.viewVehicle}</Link>
</div>
</div>
</article>
))}
</div>
</section>
</div>
</main>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
const plans = [
{
name: 'Starter',
price: 'MAD 499',
cadence: '/month',
},
{
name: 'Growth',
price: 'MAD 999',
cadence: '/month',
highlighted: true,
},
{
name: 'Pro',
price: 'Custom',
cadence: '',
},
]
export default function PricingPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const localizedPlans = plans.map((plan) => ({
...plan,
description:
plan.name === 'Starter'
? dict.pricing.plans.starterDescription
: plan.name === 'Growth'
? dict.pricing.plans.growthDescription
: dict.pricing.plans.proDescription,
features:
plan.name === 'Starter'
? dict.pricing.plans.starterFeatures
: plan.name === 'Growth'
? dict.pricing.plans.growthFeatures
: dict.pricing.plans.proFeatures,
}))
const faqs = [
{
question: dict.pricing.faq.paymentMethodsQuestion,
answer: dict.pricing.faq.paymentMethodsAnswer,
},
{
question: dict.pricing.faq.connectPaymentsQuestion,
answer: dict.pricing.faq.connectPaymentsAnswer,
},
{
question: dict.pricing.faq.supportedCurrenciesQuestion,
answer: dict.pricing.faq.supportedCurrenciesAnswer,
},
]
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_18%,#f8fafc)] py-16">
<div className="shell space-y-12">
<section className="mx-auto max-w-3xl text-center">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.pricing.eyebrow}</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.pricing.title}</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
{dict.pricing.description}
</p>
</section>
<section className="grid gap-6 lg:grid-cols-3">
{localizedPlans.map((plan) => (
<article
key={plan.name}
className={`card p-8 ${plan.highlighted ? 'border-sky-300 shadow-lg shadow-sky-100' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-slate-900">{plan.name}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{plan.description}</p>
</div>
{plan.highlighted ? (
<span className="rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-sky-800">
{dict.pricing.popular}
</span>
) : null}
</div>
<div className="mt-8 flex items-end gap-2">
<span className="text-4xl font-black tracking-tight text-slate-900">{plan.price}</span>
{plan.cadence ? <span className="pb-1 text-sm text-slate-500">{plan.cadence}</span> : null}
</div>
<ul className="mt-8 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-3 text-sm text-slate-700">
<span className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-sky-100 text-xs font-bold text-sky-700">+</span>
<span>{feature}</span>
</li>
))}
</ul>
</article>
))}
</section>
<section className="card p-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-black text-slate-900">{dict.pricing.acceptedPaymentMethods}</h2>
<p className="mt-2 text-sm text-slate-600">{dict.pricing.paymentsDescription}</p>
</div>
<div className="flex flex-wrap gap-3">
<span className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-700">AmanPay</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-700">PayPal</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-700">MAD · USD · EUR</span>
</div>
</div>
</section>
<section className="grid gap-4">
{faqs.map((faq) => (
<article key={faq.question} className="card p-6">
<h3 className="text-lg font-bold text-slate-900">{faq.question}</h3>
<p className="mt-2 text-sm leading-7 text-slate-600">{faq.answer}</p>
</article>
))}
</section>
</div>
</main>
)
}
@@ -0,0 +1,127 @@
'use client'
import { useRouter, usePathname } from 'next/navigation'
import { useCallback } from 'react'
import { getPublicSiteDictionary, type PublicSiteLanguage } from '@/lib/i18n'
interface VehicleFiltersProps {
language: PublicSiteLanguage
categories: string[]
transmissions: string[]
maxPossiblePrice: number
current: {
category: string
transmission: string
maxPrice: string
}
}
export default function VehicleFilters({
language,
categories,
transmissions,
maxPossiblePrice,
current,
}: VehicleFiltersProps) {
const dict = getPublicSiteDictionary(language)
const router = useRouter()
const pathname = usePathname()
const update = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams()
const next = { ...current, [key]: value }
if (next.category) params.set('category', next.category)
if (next.transmission) params.set('transmission', next.transmission)
if (next.maxPrice) params.set('maxPrice', next.maxPrice)
const qs = params.toString()
router.push(qs ? `${pathname}?${qs}` : pathname)
},
[current, router, pathname],
)
const hasFilters = current.category || current.transmission || current.maxPrice
return (
<div className="card p-5 space-y-6">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-900">{dict.vehicles.filters.title}</h2>
{hasFilters && (
<button
onClick={() => router.push(pathname)}
className="text-xs font-medium text-sky-700 hover:underline"
>
{dict.vehicles.filters.clearAll}
</button>
)}
</div>
<fieldset>
<legend className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
{dict.vehicles.filters.category}
</legend>
<div className="space-y-1.5">
{categories.map((cat) => (
<label key={cat} className="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700">
<input
type="radio"
name="category"
value={cat}
checked={current.category === cat}
onChange={() => update('category', current.category === cat ? '' : cat)}
className="accent-sky-600"
/>
<span>{cat}</span>
</label>
))}
</div>
</fieldset>
<fieldset>
<legend className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
{dict.vehicles.filters.transmission}
</legend>
<div className="space-y-1.5">
{transmissions.map((tx) => (
<label key={tx} className="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700">
<input
type="radio"
name="transmission"
value={tx}
checked={current.transmission === tx}
onChange={() => update('transmission', current.transmission === tx ? '' : tx)}
className="accent-sky-600"
/>
<span>{tx}</span>
</label>
))}
</div>
</fieldset>
{maxPossiblePrice > 0 && (
<fieldset>
<legend className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
{dict.vehicles.filters.maxPricePerDay}
</legend>
<input
type="range"
min={0}
max={maxPossiblePrice}
step={50}
value={current.maxPrice || maxPossiblePrice}
onChange={(e) =>
update('maxPrice', e.target.value === String(maxPossiblePrice) ? '' : e.target.value)
}
className="w-full accent-sky-600"
/>
<p className="mt-1 text-sm text-slate-600">
{dict.vehicles.filters.upTo}{' '}
<span className="font-semibold text-slate-900">
{current.maxPrice ? `${Number(current.maxPrice).toLocaleString()} MAD` : `${maxPossiblePrice.toLocaleString()} MAD`}
</span>
</p>
</fieldset>
)}
</div>
)
}
@@ -0,0 +1,70 @@
import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
seats: number
transmission: string
fuelType: string
features: string[]
photos: string[]
}
export default async function VehiclePage({ params }: { params: { id: string } }) {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const vehicle = await siteFetchOrDefault<VehicleDetail | null>(`/site/${DEFAULT_COMPANY_SLUG}/vehicles/${params.id}`, null)
if (!vehicle) {
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell">
<section className="card p-8">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.vehicles.unavailable.eyebrow}</p>
<h1 className="mt-3 text-3xl font-black text-slate-900">{dict.vehicles.unavailable.title}</h1>
<p className="mt-3 text-slate-600">{dict.vehicles.unavailable.body}</p>
<div className="mt-6">
<Link href="/" className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{dict.vehicles.unavailable.backHome}</Link>
</div>
</section>
</div>
</main>
)
}
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<section className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="card overflow-hidden">
{/* 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" />
</div>
))}
</section>
<aside className="card p-6 h-fit">
<h1 className="text-4xl font-black text-slate-900">{vehicle.make} {vehicle.model}</h1>
<p className="mt-3 text-slate-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
<p className="mt-6 text-3xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-slate-500"> {dict.vehicles.detail.perDay}</span></p>
<div className="mt-6 flex flex-wrap gap-2">
{vehicle.features.map((feature) => (
<span key={feature} className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{feature}</span>
))}
</div>
<div className="mt-8 flex gap-3">
<Link href={`/book?vehicleId=${vehicle.id}`} className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{dict.vehicles.detail.bookNow}</Link>
<Link href="/" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{dict.vehicles.detail.back}</Link>
</div>
</aside>
</div>
</main>
)
}
+141
View File
@@ -0,0 +1,141 @@
import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import VehicleFilters from './VehicleFilters'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
seats: number
transmission: string
fuelType: string
photos: string[]
}
interface PageProps {
searchParams?: {
category?: string
maxPrice?: string
transmission?: string
}
}
export const dynamic = 'force-dynamic'
export default async function VehiclesPage({ searchParams = {} }: PageProps) {
const slug = DEFAULT_COMPANY_SLUG
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const vehicles = await siteFetchOrDefault<Vehicle[]>(`/site/${slug}/vehicles`, [])
const categories = Array.from(new Set(vehicles.map((v) => v.category))).sort()
const transmissions = Array.from(new Set(vehicles.map((v) => v.transmission))).sort()
const maxPossiblePrice = vehicles.reduce((max, v) => Math.max(max, v.dailyRate), 0)
const filtered = vehicles.filter((v) => {
if (searchParams.category && v.category !== searchParams.category) return false
if (searchParams.transmission && v.transmission !== searchParams.transmission) return false
if (searchParams.maxPrice && v.dailyRate > Number(searchParams.maxPrice)) return false
return true
})
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell">
<div className="mb-8">
<h1 className="text-4xl font-black text-slate-900">{dict.vehicles.title}</h1>
<p className="mt-2 text-slate-500">
{dict.vehicles.available(filtered.length)}
</p>
</div>
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
{/* Filter sidebar (client component) */}
<aside>
<VehicleFilters
language={language}
categories={categories}
transmissions={transmissions}
maxPossiblePrice={maxPossiblePrice}
current={{
category: searchParams.category ?? '',
transmission: searchParams.transmission ?? '',
maxPrice: searchParams.maxPrice ?? '',
}}
/>
</aside>
{/* Vehicle grid */}
<section>
{filtered.length === 0 ? (
<div className="card p-10 text-center">
<p className="text-lg font-semibold text-slate-700">{dict.vehicles.noVehiclesTitle}</p>
<p className="mt-2 text-sm text-slate-500">{dict.vehicles.noVehiclesBody}</p>
<Link
href="/vehicles"
className="mt-6 inline-block rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white"
>
{dict.vehicles.clearFilters}
</Link>
</div>
) : (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((vehicle) => (
<article key={vehicle.id} className="card overflow-hidden flex flex-col">
<div className="aspect-[16/10] bg-slate-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"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-400">
{dict.vehicles.noPhoto}
</div>
)}
</div>
<div className="flex flex-1 flex-col p-5">
<h2 className="text-xl font-bold text-slate-900">
{vehicle.make} {vehicle.model}
</h2>
<p className="mt-1 text-sm text-slate-500">{vehicle.category}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-600">
<span className="rounded-full bg-slate-100 px-3 py-1">{vehicle.transmission}</span>
<span className="rounded-full bg-slate-100 px-3 py-1">{dict.vehicles.seats(vehicle.seats)}</span>
<span className="rounded-full bg-slate-100 px-3 py-1">{vehicle.fuelType}</span>
</div>
<div className="mt-auto pt-4 flex items-end justify-between">
<div>
<p className="text-xl font-black text-slate-900">
{formatCurrency(vehicle.dailyRate, 'MAD')}
</p>
<p className="text-xs text-slate-500">{dict.vehicles.perDay}</p>
</div>
<Link
href={`/vehicles/${vehicle.id}`}
className="rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-slate-700 transition-colors"
>
{dict.vehicles.viewDetails}
</Link>
</div>
</div>
</article>
))}
</div>
)}
</section>
</div>
</div>
</main>
)
}