fixing platform admin
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
© {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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PUBLIC_SITE_LANGUAGE_COOKIE, type PublicSiteLanguage } from '@/lib/i18n'
|
||||
|
||||
const labels: Record<PublicSiteLanguage, string> = {
|
||||
en: 'Language',
|
||||
fr: 'Langue',
|
||||
ar: 'اللغة',
|
||||
}
|
||||
|
||||
export default function PublicLanguageSwitcher({
|
||||
currentLanguage,
|
||||
}: {
|
||||
currentLanguage: PublicSiteLanguage
|
||||
}) {
|
||||
const [language, setLanguage] = useState(currentLanguage)
|
||||
|
||||
useEffect(() => {
|
||||
setLanguage(currentLanguage)
|
||||
}, [currentLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
window.localStorage.setItem('public-site-language', language)
|
||||
}, [language])
|
||||
|
||||
function handleChange(nextLanguage: PublicSiteLanguage) {
|
||||
setLanguage(nextLanguage)
|
||||
document.cookie = `${PUBLIC_SITE_LANGUAGE_COOKIE}=${nextLanguage}; path=/; max-age=31536000; samesite=lax`
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{labels[language]}
|
||||
</span>
|
||||
{(['en', 'fr', 'ar'] as PublicSiteLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => handleChange(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
export const DEFAULT_COMPANY_SLUG = process.env.NEXT_PUBLIC_COMPANY_SLUG ?? 'demo'
|
||||
|
||||
export class SiteApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message)
|
||||
this.name = 'SiteApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export async function siteFetch<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new SiteApiError(json?.message ?? 'Request failed', res.status, json?.error)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
export async function siteFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await siteFetch<T>(path)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { PUBLIC_SITE_LANGUAGE_COOKIE, isPublicSiteLanguage, type PublicSiteLanguage } from './i18n'
|
||||
|
||||
export function getPublicSiteLanguage(): PublicSiteLanguage {
|
||||
const cookieValue = cookies().get(PUBLIC_SITE_LANGUAGE_COOKIE)?.value
|
||||
return isPublicSiteLanguage(cookieValue) ? cookieValue : 'en'
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
export type PublicSiteLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
export const PUBLIC_SITE_LANGUAGE_COOKIE = 'public-site-language'
|
||||
|
||||
export function isPublicSiteLanguage(value: string | null | undefined): value is PublicSiteLanguage {
|
||||
return value === 'en' || value === 'fr' || value === 'ar'
|
||||
}
|
||||
|
||||
export type PublicSiteDictionary = {
|
||||
lang: string
|
||||
dir: 'ltr' | 'rtl'
|
||||
siteNameFallback: string
|
||||
rentalCompanyFallback: string
|
||||
nav: {
|
||||
about: string
|
||||
vehicles: string
|
||||
offers: string
|
||||
pricing: string
|
||||
blog: string
|
||||
contact: string
|
||||
bookCar: string
|
||||
siteNavigation: string
|
||||
}
|
||||
home: {
|
||||
heroTitleFallback: string
|
||||
heroDescription: string
|
||||
viewOffers: string
|
||||
contactCompany: string
|
||||
heroImageMissing: string
|
||||
activeOffers: string
|
||||
seeAllOffers: string
|
||||
noActiveOffers: string
|
||||
publishedVehicles: string
|
||||
available: (count: number) => string
|
||||
viewVehicle: string
|
||||
}
|
||||
about: {
|
||||
eyebrow: string
|
||||
defaultTagline: string
|
||||
location: string
|
||||
phone: string
|
||||
email: string
|
||||
whatsapp: string
|
||||
locationMissing: string
|
||||
phoneMissing: string
|
||||
emailMissing: string
|
||||
whatsappMissing: string
|
||||
heroImageMissing: string
|
||||
directBookingTitle: string
|
||||
directBookingBody: string
|
||||
publishedFleetTitle: string
|
||||
publishedFleetBody: string
|
||||
marketplaceReadyTitle: string
|
||||
marketplaceReadyBody: string
|
||||
companyDetails: string
|
||||
companyAddressMissing: string
|
||||
}
|
||||
contact: {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
offers: {
|
||||
title: string
|
||||
empty: string
|
||||
noDescription: string
|
||||
bookOffer: string
|
||||
}
|
||||
pricing: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
popular: string
|
||||
acceptedPaymentMethods: string
|
||||
paymentsDescription: string
|
||||
faq: {
|
||||
paymentMethodsQuestion: string
|
||||
paymentMethodsAnswer: string
|
||||
connectPaymentsQuestion: string
|
||||
connectPaymentsAnswer: string
|
||||
supportedCurrenciesQuestion: string
|
||||
supportedCurrenciesAnswer: string
|
||||
}
|
||||
plans: {
|
||||
starterDescription: string
|
||||
growthDescription: string
|
||||
proDescription: string
|
||||
starterFeatures: string[]
|
||||
growthFeatures: string[]
|
||||
proFeatures: string[]
|
||||
}
|
||||
}
|
||||
blog: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
draftPlaceholder: string
|
||||
posts: Array<{
|
||||
title: string
|
||||
excerpt: string
|
||||
category: string
|
||||
}>
|
||||
}
|
||||
vehicles: {
|
||||
title: string
|
||||
available: (count: number) => string
|
||||
noVehiclesTitle: string
|
||||
noVehiclesBody: string
|
||||
clearFilters: string
|
||||
noPhoto: string
|
||||
seats: (count: number) => string
|
||||
perDay: string
|
||||
viewDetails: string
|
||||
filters: {
|
||||
title: string
|
||||
clearAll: string
|
||||
category: string
|
||||
transmission: string
|
||||
maxPricePerDay: string
|
||||
upTo: string
|
||||
}
|
||||
unavailable: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
body: string
|
||||
backHome: string
|
||||
}
|
||||
detail: {
|
||||
perDay: string
|
||||
bookNow: string
|
||||
back: string
|
||||
}
|
||||
}
|
||||
booking: {
|
||||
loadingForm: string
|
||||
eyebrow: string
|
||||
title: string
|
||||
steps: {
|
||||
selectDates: string
|
||||
driverDetails: string
|
||||
reviewBooking: string
|
||||
payment: string
|
||||
}
|
||||
loadingVehicle: string
|
||||
vehicleNotFound: string
|
||||
vehicleLoadError: string
|
||||
bookingOptionsLoadError: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
continueToDriverDetails: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
driverLicense: string
|
||||
nationality: string
|
||||
dateOfBirth: string
|
||||
licenseIssued: string
|
||||
licenseExpiry: string
|
||||
insuranceAndPickupPolicies: string
|
||||
insuranceHint: string
|
||||
requiredCoverage: string
|
||||
none: string
|
||||
noOptionalInsurance: string
|
||||
additionalDriver: string
|
||||
charge: string
|
||||
promoCode: string
|
||||
enterCode: string
|
||||
apply: string
|
||||
appliedPromo: (title: string, discount: number) => string
|
||||
back: string
|
||||
reviewBookingCta: string
|
||||
confirmReservation: string
|
||||
processing: string
|
||||
reservationCreated: string
|
||||
manualApprovalRequired: string
|
||||
totalDue: string
|
||||
currency: string
|
||||
paymentMethod: string
|
||||
paymentMethods: {
|
||||
amanpay: { title: string; description: string }
|
||||
paypal: { title: string; description: string }
|
||||
cash: { title: string; description: string }
|
||||
}
|
||||
redirectingToPayment: string
|
||||
confirmPayAtPickup: string
|
||||
payWith: (provider: string) => string
|
||||
summary: {
|
||||
vehicle: string
|
||||
dates: string
|
||||
primaryDriver: string
|
||||
email: string
|
||||
driverLicense: string
|
||||
licenseExpiry: string
|
||||
additionalDriver: string
|
||||
promoCode: string
|
||||
discount: (discount: number) => string
|
||||
total: string
|
||||
}
|
||||
validation: {
|
||||
pickStartDate: string
|
||||
pickEndDate: string
|
||||
endDateAfterStart: string
|
||||
firstNameRequired: string
|
||||
lastNameRequired: string
|
||||
validEmailRequired: string
|
||||
driverLicenseRequired: string
|
||||
dateOfBirthRequired: string
|
||||
licenseExpiryRequired: string
|
||||
licenseIssueDateRequired: string
|
||||
additionalDriverFirstNameRequired: string
|
||||
additionalDriverLastNameRequired: string
|
||||
additionalDriverLicenseRequired: string
|
||||
}
|
||||
promo: {
|
||||
invalid: string
|
||||
unavailable: string
|
||||
}
|
||||
submit: {
|
||||
failed: string
|
||||
network: string
|
||||
}
|
||||
payment: {
|
||||
initiateFailed: string
|
||||
network: string
|
||||
}
|
||||
}
|
||||
confirmation: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
bookingSummary: string
|
||||
vehicle: string
|
||||
customer: string
|
||||
email: string
|
||||
dates: string
|
||||
total: string
|
||||
reference: string
|
||||
status: string
|
||||
browseMoreVehicles: string
|
||||
backHome: string
|
||||
}
|
||||
}
|
||||
|
||||
const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
en: {
|
||||
lang: 'en',
|
||||
dir: 'ltr',
|
||||
siteNameFallback: 'Company site',
|
||||
rentalCompanyFallback: 'Rental company',
|
||||
nav: {
|
||||
about: 'About',
|
||||
vehicles: 'Vehicles',
|
||||
offers: 'Offers',
|
||||
pricing: 'Pricing',
|
||||
blog: 'Blog',
|
||||
contact: 'Contact',
|
||||
bookCar: 'Book a car',
|
||||
siteNavigation: 'Site navigation',
|
||||
},
|
||||
home: {
|
||||
heroTitleFallback: 'Book directly with the rental company.',
|
||||
heroDescription: 'This branded site handles booking and payment directly. Marketplace discovery routes renters here for checkout.',
|
||||
viewOffers: 'View offers',
|
||||
contactCompany: 'Contact company',
|
||||
heroImageMissing: 'Hero image not configured yet.',
|
||||
activeOffers: 'Active offers',
|
||||
seeAllOffers: 'See all offers',
|
||||
noActiveOffers: 'No active offers right now.',
|
||||
publishedVehicles: 'Published vehicles',
|
||||
available: (count) => `${count} available`,
|
||||
viewVehicle: 'View vehicle',
|
||||
},
|
||||
about: {
|
||||
eyebrow: 'About',
|
||||
defaultTagline: 'This company uses RentalDriveGo to publish vehicles, run bookings, and accept direct rental payments.',
|
||||
location: 'Location',
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
whatsapp: 'WhatsApp',
|
||||
locationMissing: 'Location not published yet',
|
||||
phoneMissing: 'Phone not published yet',
|
||||
emailMissing: 'Email not published yet',
|
||||
whatsappMissing: 'WhatsApp not published yet',
|
||||
heroImageMissing: 'Add a hero image in branding settings to personalize this page.',
|
||||
directBookingTitle: 'Direct booking',
|
||||
directBookingBody: 'Renters book and pay directly on the company site instead of checking out on the marketplace.',
|
||||
publishedFleetTitle: 'Published fleet',
|
||||
publishedFleetBody: 'Vehicle photos, pricing, and availability are managed from the private dashboard and published here automatically.',
|
||||
marketplaceReadyTitle: 'Marketplace ready',
|
||||
marketplaceReadyBody: 'Marketplace discovery redirects qualified renters into this branded site for final booking and payment.',
|
||||
companyDetails: 'Company details',
|
||||
companyAddressMissing: 'A public address has not been configured yet.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact',
|
||||
description: "Use the company's public contact details or extend this page to call the `/site/:slug/contact` endpoint.",
|
||||
},
|
||||
offers: {
|
||||
title: 'Offers',
|
||||
empty: 'No offers are available right now.',
|
||||
noDescription: 'No description provided.',
|
||||
bookOffer: 'Book with this offer',
|
||||
},
|
||||
pricing: {
|
||||
eyebrow: 'Pricing',
|
||||
title: 'Plans for growing rental operations',
|
||||
description: 'Choose a plan that matches your fleet size and booking volume. The design spec supports AmanPay and PayPal, plus MAD, USD, and EUR billing.',
|
||||
popular: 'Popular',
|
||||
acceptedPaymentMethods: 'Accepted payment methods',
|
||||
paymentsDescription: 'Pay securely with AmanPay or PayPal. Supported currencies: MAD, USD, EUR.',
|
||||
faq: {
|
||||
paymentMethodsQuestion: 'Which payment methods are supported?',
|
||||
paymentMethodsAnswer: 'Companies can use AmanPay or PayPal for subscriptions, and renters pay companies directly through the company-configured payment methods.',
|
||||
connectPaymentsQuestion: 'Can I start before connecting payments?',
|
||||
connectPaymentsAnswer: 'Yes. Trialing companies can configure branding, fleet, and offers before activating their live payment setup.',
|
||||
supportedCurrenciesQuestion: 'Which currencies are supported?',
|
||||
supportedCurrenciesAnswer: 'The current design supports MAD, USD, and EUR across subscription billing and rental pricing.',
|
||||
},
|
||||
plans: {
|
||||
starterDescription: 'For smaller fleets that need direct bookings and a branded web presence.',
|
||||
growthDescription: 'For operators scaling marketing, offers, and multi-role workflows.',
|
||||
proDescription: 'For larger operators that need branding control and deeper operational flexibility.',
|
||||
starterFeatures: ['Branded booking site', 'Fleet publishing', 'Marketplace visibility', 'Basic team access'],
|
||||
growthFeatures: ['Everything in Starter', 'Featured marketplace offers', 'Advanced pricing rules', 'Expanded reporting'],
|
||||
proFeatures: ['Custom branding', 'Priority onboarding', 'Advanced operations', 'White-label polish'],
|
||||
},
|
||||
},
|
||||
blog: {
|
||||
eyebrow: 'Blog',
|
||||
title: 'Insights for rental operators',
|
||||
description: 'The design spec includes a standard blog route. This page seeds that route with editorial-style placeholders that can later be replaced by CMS-backed content.',
|
||||
draftPlaceholder: 'Draft article placeholder',
|
||||
posts: [
|
||||
{
|
||||
title: 'How direct booking changes rental margins',
|
||||
excerpt: 'Why moving renters from marketplace discovery into a branded booking experience protects pricing and customer relationships.',
|
||||
category: 'Operations',
|
||||
},
|
||||
{
|
||||
title: 'What to publish on a modern rental fleet page',
|
||||
excerpt: 'A practical checklist for vehicle photos, specs, pricing clarity, and trust signals that convert browsers into bookings.',
|
||||
category: 'Marketing',
|
||||
},
|
||||
{
|
||||
title: 'Using promotions without breaking your pricing model',
|
||||
excerpt: 'Featured offers work best when they are time-bound, measurable, and attached to actual fleet strategy.',
|
||||
category: 'Revenue',
|
||||
},
|
||||
],
|
||||
},
|
||||
vehicles: {
|
||||
title: 'Our Fleet',
|
||||
available: (count) => `${count} vehicle${count !== 1 ? 's' : ''} available`,
|
||||
noVehiclesTitle: 'No vehicles match your filters.',
|
||||
noVehiclesBody: 'Try adjusting the category, price, or transmission filter.',
|
||||
clearFilters: 'Clear filters',
|
||||
noPhoto: 'No photo',
|
||||
seats: (count) => `${count} seats`,
|
||||
perDay: 'per day',
|
||||
viewDetails: 'View details',
|
||||
filters: {
|
||||
title: 'Filters',
|
||||
clearAll: 'Clear all',
|
||||
category: 'Category',
|
||||
transmission: 'Transmission',
|
||||
maxPricePerDay: 'Max price / day',
|
||||
upTo: 'Up to',
|
||||
},
|
||||
unavailable: {
|
||||
eyebrow: 'Site unavailable',
|
||||
title: 'Vehicle details are temporarily unavailable.',
|
||||
body: 'The public site is running, but the local database is not reachable right now.',
|
||||
backHome: 'Back to home',
|
||||
},
|
||||
detail: {
|
||||
perDay: '/ day',
|
||||
bookNow: 'Book now',
|
||||
back: 'Back',
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
loadingForm: 'Loading booking form...',
|
||||
eyebrow: 'Booking flow',
|
||||
title: 'Complete your reservation',
|
||||
steps: {
|
||||
selectDates: 'Select dates',
|
||||
driverDetails: 'Driver details',
|
||||
reviewBooking: 'Review booking',
|
||||
payment: 'Payment',
|
||||
},
|
||||
loadingVehicle: 'Loading vehicle...',
|
||||
vehicleNotFound: 'Vehicle not found.',
|
||||
vehicleLoadError: 'Could not load vehicle details.',
|
||||
bookingOptionsLoadError: 'Could not load booking options.',
|
||||
startDate: 'Start date',
|
||||
endDate: 'End date',
|
||||
continueToDriverDetails: 'Continue to driver details ->',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
driverLicense: 'Driver license',
|
||||
nationality: 'Nationality',
|
||||
dateOfBirth: 'Date of birth',
|
||||
licenseIssued: 'License issued',
|
||||
licenseExpiry: 'License expiry',
|
||||
insuranceAndPickupPolicies: 'Insurance and pickup policies',
|
||||
insuranceHint: 'Select optional protection before confirming your reservation.',
|
||||
requiredCoverage: 'Required coverage',
|
||||
none: 'None',
|
||||
noOptionalInsurance: 'No optional insurance add-ons are available for this company.',
|
||||
additionalDriver: 'Additional driver',
|
||||
charge: 'Charge',
|
||||
promoCode: 'Promo code',
|
||||
enterCode: 'Enter code',
|
||||
apply: 'Apply',
|
||||
appliedPromo: (title, discount) => `✓ "${title}" applied - ${discount}% off`,
|
||||
back: '<- Back',
|
||||
reviewBookingCta: 'Review booking ->',
|
||||
confirmReservation: 'Confirm reservation',
|
||||
processing: 'Processing...',
|
||||
reservationCreated: '✓ Reservation created - choose how you would like to pay',
|
||||
manualApprovalRequired: 'Driver license review is required before online payment. You can confirm with pay-at-pickup or wait for staff approval.',
|
||||
totalDue: 'Total due',
|
||||
currency: 'Currency',
|
||||
paymentMethod: 'Payment method',
|
||||
paymentMethods: {
|
||||
amanpay: { title: 'AmanPay', description: 'Secure online payment via AmanPay' },
|
||||
paypal: { title: 'PayPal', description: 'Pay with your PayPal account or card' },
|
||||
cash: { title: 'Pay at pickup', description: 'Pay cash or by card when you pick up the vehicle' },
|
||||
},
|
||||
redirectingToPayment: 'Redirecting to payment...',
|
||||
confirmPayAtPickup: 'Confirm - pay at pickup',
|
||||
payWith: (provider) => `Pay with ${provider} ->`,
|
||||
summary: {
|
||||
vehicle: 'Vehicle',
|
||||
dates: 'Dates',
|
||||
primaryDriver: 'Primary driver',
|
||||
email: 'Email',
|
||||
driverLicense: 'Driver license',
|
||||
licenseExpiry: 'License expiry',
|
||||
additionalDriver: 'Additional driver',
|
||||
promoCode: 'Promo code',
|
||||
discount: (discount) => `Discount (${discount}%)`,
|
||||
total: 'Total',
|
||||
},
|
||||
validation: {
|
||||
pickStartDate: 'Pick a start date.',
|
||||
pickEndDate: 'Pick an end date.',
|
||||
endDateAfterStart: 'End date must be after start date.',
|
||||
firstNameRequired: 'First name required.',
|
||||
lastNameRequired: 'Last name required.',
|
||||
validEmailRequired: 'Valid email required.',
|
||||
driverLicenseRequired: 'Driver license required.',
|
||||
dateOfBirthRequired: 'Date of birth required.',
|
||||
licenseExpiryRequired: 'License expiry required.',
|
||||
licenseIssueDateRequired: 'License issue date required.',
|
||||
additionalDriverFirstNameRequired: 'Additional driver first name required.',
|
||||
additionalDriverLastNameRequired: 'Additional driver last name required.',
|
||||
additionalDriverLicenseRequired: 'Additional driver license required.',
|
||||
},
|
||||
promo: {
|
||||
invalid: 'Invalid or expired promo code.',
|
||||
unavailable: 'Could not validate promo code. Please try again.',
|
||||
},
|
||||
submit: {
|
||||
failed: 'Booking failed. Please try again.',
|
||||
network: 'Network error. Please check your connection and try again.',
|
||||
},
|
||||
payment: {
|
||||
initiateFailed: 'Payment could not be initiated.',
|
||||
network: 'Network error. Please try again.',
|
||||
},
|
||||
},
|
||||
confirmation: {
|
||||
eyebrow: 'Reservation saved',
|
||||
title: 'Booking request received.',
|
||||
description: 'The reservation has been created in draft status and will be confirmed by staff or after payment.',
|
||||
bookingSummary: 'Booking summary',
|
||||
vehicle: 'Vehicle',
|
||||
customer: 'Customer',
|
||||
email: 'Email',
|
||||
dates: 'Dates',
|
||||
total: 'Total',
|
||||
reference: 'Reference',
|
||||
status: 'Status',
|
||||
browseMoreVehicles: 'Browse more vehicles',
|
||||
backHome: 'Back to home',
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
lang: 'fr',
|
||||
dir: 'ltr',
|
||||
siteNameFallback: "Site de l'entreprise",
|
||||
rentalCompanyFallback: 'Société de location',
|
||||
nav: {
|
||||
about: 'À propos',
|
||||
vehicles: 'Véhicules',
|
||||
offers: 'Offres',
|
||||
pricing: 'Tarifs',
|
||||
blog: 'Blog',
|
||||
contact: 'Contact',
|
||||
bookCar: 'Réserver une voiture',
|
||||
siteNavigation: 'Navigation du site',
|
||||
},
|
||||
home: {
|
||||
heroTitleFallback: "Réservez directement auprès de l'entreprise de location.",
|
||||
heroDescription: 'Ce site de marque gère directement la réservation et le paiement. La découverte sur la marketplace redirige les locataires ici pour finaliser la commande.',
|
||||
viewOffers: 'Voir les offres',
|
||||
contactCompany: "Contacter l'entreprise",
|
||||
heroImageMissing: "L'image principale n'est pas encore configurée.",
|
||||
activeOffers: 'Offres actives',
|
||||
seeAllOffers: 'Voir toutes les offres',
|
||||
noActiveOffers: "Aucune offre active pour le moment.",
|
||||
publishedVehicles: 'Véhicules publiés',
|
||||
available: (count) => `${count} disponible${count > 1 ? 's' : ''}`,
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
},
|
||||
about: {
|
||||
eyebrow: 'À propos',
|
||||
defaultTagline: 'Cette entreprise utilise RentalDriveGo pour publier ses véhicules, gérer les réservations et accepter des paiements directs.',
|
||||
location: 'Localisation',
|
||||
phone: 'Téléphone',
|
||||
email: 'E-mail',
|
||||
whatsapp: 'WhatsApp',
|
||||
locationMissing: 'Localisation non publiée',
|
||||
phoneMissing: 'Téléphone non publié',
|
||||
emailMissing: 'E-mail non publié',
|
||||
whatsappMissing: 'WhatsApp non publié',
|
||||
heroImageMissing: 'Ajoutez une image principale dans les paramètres de marque pour personnaliser cette page.',
|
||||
directBookingTitle: 'Réservation directe',
|
||||
directBookingBody: "Les locataires réservent et paient directement sur le site de l'entreprise au lieu de finaliser sur la marketplace.",
|
||||
publishedFleetTitle: 'Flotte publiée',
|
||||
publishedFleetBody: 'Les photos, tarifs et disponibilités des véhicules sont gérés depuis le tableau de bord privé et publiés ici automatiquement.',
|
||||
marketplaceReadyTitle: 'Prêt pour la marketplace',
|
||||
marketplaceReadyBody: 'La découverte sur la marketplace redirige les locataires qualifiés vers ce site de marque pour la réservation finale et le paiement.',
|
||||
companyDetails: "Détails de l'entreprise",
|
||||
companyAddressMissing: "Aucune adresse publique n'a encore été configurée.",
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact',
|
||||
description: "Utilisez les coordonnées publiques de l'entreprise ou étendez cette page pour appeler l'endpoint `/site/:slug/contact`.",
|
||||
},
|
||||
offers: {
|
||||
title: 'Offres',
|
||||
empty: "Aucune offre n'est disponible pour le moment.",
|
||||
noDescription: 'Aucune description fournie.',
|
||||
bookOffer: 'Réserver avec cette offre',
|
||||
},
|
||||
pricing: {
|
||||
eyebrow: 'Tarifs',
|
||||
title: 'Des formules pour faire grandir votre activité',
|
||||
description: 'Choisissez une formule adaptée à la taille de votre flotte et à votre volume de réservations. La spécification prend en charge AmanPay et PayPal, ainsi que la facturation en MAD, USD et EUR.',
|
||||
popular: 'Populaire',
|
||||
acceptedPaymentMethods: 'Moyens de paiement acceptés',
|
||||
paymentsDescription: 'Payez en toute sécurité avec AmanPay ou PayPal. Devises prises en charge : MAD, USD, EUR.',
|
||||
faq: {
|
||||
paymentMethodsQuestion: 'Quels moyens de paiement sont pris en charge ?',
|
||||
paymentMethodsAnswer: "Les entreprises peuvent utiliser AmanPay ou PayPal pour les abonnements, et les locataires paient directement l'entreprise via les méthodes configurées par celle-ci.",
|
||||
connectPaymentsQuestion: 'Puis-je commencer avant de connecter les paiements ?',
|
||||
connectPaymentsAnswer: "Oui. Les entreprises en essai peuvent configurer leur marque, leur flotte et leurs offres avant d'activer leur configuration de paiement en direct.",
|
||||
supportedCurrenciesQuestion: 'Quelles devises sont prises en charge ?',
|
||||
supportedCurrenciesAnswer: 'Le design actuel prend en charge MAD, USD et EUR pour la facturation des abonnements et les tarifs de location.',
|
||||
},
|
||||
plans: {
|
||||
starterDescription: 'Pour les petites flottes qui ont besoin de réservations directes et d’une présence web de marque.',
|
||||
growthDescription: 'Pour les opérateurs qui développent leur marketing, leurs offres et leurs workflows multi-rôles.',
|
||||
proDescription: 'Pour les grands opérateurs qui ont besoin de contrôle de marque et d’une flexibilité opérationnelle plus profonde.',
|
||||
starterFeatures: ['Site de réservation de marque', 'Publication de flotte', 'Visibilité marketplace', "Accès d'équipe basique"],
|
||||
growthFeatures: ['Tout le contenu de Starter', 'Offres marketplace mises en avant', 'Règles tarifaires avancées', 'Reporting élargi'],
|
||||
proFeatures: ['Marque personnalisée', 'Onboarding prioritaire', 'Opérations avancées', 'Finition white-label'],
|
||||
},
|
||||
},
|
||||
blog: {
|
||||
eyebrow: 'Blog',
|
||||
title: 'Analyses pour les opérateurs de location',
|
||||
description: 'La spécification inclut une route blog standard. Cette page alimente cette route avec des espaces réservés éditoriaux qui pourront ensuite être remplacés par un contenu relié à un CMS.',
|
||||
draftPlaceholder: "Brouillon d'article",
|
||||
posts: [
|
||||
{
|
||||
title: 'Comment la réservation directe change les marges',
|
||||
excerpt: 'Pourquoi déplacer les locataires de la découverte marketplace vers une expérience de réservation de marque protège les prix et la relation client.',
|
||||
category: 'Opérations',
|
||||
},
|
||||
{
|
||||
title: "Que publier sur une page de flotte moderne",
|
||||
excerpt: 'Une checklist pratique pour les photos, spécifications, clarté des prix et signaux de confiance qui convertissent les visiteurs en réservations.',
|
||||
category: 'Marketing',
|
||||
},
|
||||
{
|
||||
title: 'Utiliser des promotions sans casser votre modèle tarifaire',
|
||||
excerpt: 'Les offres mises en avant fonctionnent mieux lorsqu’elles sont limitées dans le temps, mesurables et alignées sur une vraie stratégie de flotte.',
|
||||
category: 'Revenus',
|
||||
},
|
||||
],
|
||||
},
|
||||
vehicles: {
|
||||
title: 'Notre flotte',
|
||||
available: (count) => `${count} véhicule${count > 1 ? 's' : ''} disponible${count > 1 ? 's' : ''}`,
|
||||
noVehiclesTitle: 'Aucun véhicule ne correspond à vos filtres.',
|
||||
noVehiclesBody: 'Essayez de modifier le filtre de catégorie, de prix ou de transmission.',
|
||||
clearFilters: 'Effacer les filtres',
|
||||
noPhoto: 'Pas de photo',
|
||||
seats: (count) => `${count} places`,
|
||||
perDay: 'par jour',
|
||||
viewDetails: 'Voir les détails',
|
||||
filters: {
|
||||
title: 'Filtres',
|
||||
clearAll: 'Tout effacer',
|
||||
category: 'Catégorie',
|
||||
transmission: 'Transmission',
|
||||
maxPricePerDay: 'Prix max / jour',
|
||||
upTo: "Jusqu'à",
|
||||
},
|
||||
unavailable: {
|
||||
eyebrow: 'Site indisponible',
|
||||
title: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||
body: 'Le site public fonctionne, mais la base de données locale est actuellement inaccessible.',
|
||||
backHome: "Retour à l'accueil",
|
||||
},
|
||||
detail: {
|
||||
perDay: '/ jour',
|
||||
bookNow: 'Réserver maintenant',
|
||||
back: 'Retour',
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
loadingForm: 'Chargement du formulaire...',
|
||||
eyebrow: 'Parcours de réservation',
|
||||
title: 'Finalisez votre réservation',
|
||||
steps: {
|
||||
selectDates: 'Choisir les dates',
|
||||
driverDetails: 'Informations conducteur',
|
||||
reviewBooking: 'Vérifier la réservation',
|
||||
payment: 'Paiement',
|
||||
},
|
||||
loadingVehicle: 'Chargement du véhicule...',
|
||||
vehicleNotFound: 'Véhicule introuvable.',
|
||||
vehicleLoadError: 'Impossible de charger les détails du véhicule.',
|
||||
bookingOptionsLoadError: 'Impossible de charger les options de réservation.',
|
||||
startDate: 'Date de début',
|
||||
endDate: 'Date de fin',
|
||||
continueToDriverDetails: 'Continuer vers les informations conducteur ->',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'E-mail',
|
||||
phone: 'Téléphone',
|
||||
driverLicense: 'Permis de conduire',
|
||||
nationality: 'Nationalité',
|
||||
dateOfBirth: 'Date de naissance',
|
||||
licenseIssued: 'Permis délivré le',
|
||||
licenseExpiry: 'Expiration du permis',
|
||||
insuranceAndPickupPolicies: 'Assurance et politiques de remise',
|
||||
insuranceHint: 'Sélectionnez une protection optionnelle avant de confirmer votre réservation.',
|
||||
requiredCoverage: 'Couverture obligatoire',
|
||||
none: 'Aucune',
|
||||
noOptionalInsurance: "Aucune assurance optionnelle n'est disponible pour cette entreprise.",
|
||||
additionalDriver: 'Conducteur additionnel',
|
||||
charge: 'Frais',
|
||||
promoCode: 'Code promo',
|
||||
enterCode: 'Entrer le code',
|
||||
apply: 'Appliquer',
|
||||
appliedPromo: (title, discount) => `✓ "${title}" appliqué - ${discount}% de réduction`,
|
||||
back: '<- Retour',
|
||||
reviewBookingCta: 'Vérifier la réservation ->',
|
||||
confirmReservation: 'Confirmer la réservation',
|
||||
processing: 'Traitement...',
|
||||
reservationCreated: '✓ Réservation créée - choisissez votre mode de paiement',
|
||||
manualApprovalRequired: 'La vérification du permis est requise avant le paiement en ligne. Vous pouvez confirmer avec paiement au retrait ou attendre la validation du personnel.',
|
||||
totalDue: 'Total à payer',
|
||||
currency: 'Devise',
|
||||
paymentMethod: 'Mode de paiement',
|
||||
paymentMethods: {
|
||||
amanpay: { title: 'AmanPay', description: 'Paiement en ligne sécurisé via AmanPay' },
|
||||
paypal: { title: 'PayPal', description: 'Payez avec votre compte PayPal ou votre carte' },
|
||||
cash: { title: 'Paiement au retrait', description: 'Payez en espèces ou par carte lors de la récupération du véhicule' },
|
||||
},
|
||||
redirectingToPayment: 'Redirection vers le paiement...',
|
||||
confirmPayAtPickup: 'Confirmer - payer au retrait',
|
||||
payWith: (provider) => `Payer avec ${provider} ->`,
|
||||
summary: {
|
||||
vehicle: 'Véhicule',
|
||||
dates: 'Dates',
|
||||
primaryDriver: 'Conducteur principal',
|
||||
email: 'E-mail',
|
||||
driverLicense: 'Permis',
|
||||
licenseExpiry: 'Expiration du permis',
|
||||
additionalDriver: 'Conducteur additionnel',
|
||||
promoCode: 'Code promo',
|
||||
discount: (discount) => `Réduction (${discount}%)`,
|
||||
total: 'Total',
|
||||
},
|
||||
validation: {
|
||||
pickStartDate: 'Choisissez une date de début.',
|
||||
pickEndDate: 'Choisissez une date de fin.',
|
||||
endDateAfterStart: 'La date de fin doit être après la date de début.',
|
||||
firstNameRequired: 'Le prénom est requis.',
|
||||
lastNameRequired: 'Le nom est requis.',
|
||||
validEmailRequired: 'Un e-mail valide est requis.',
|
||||
driverLicenseRequired: 'Le permis de conduire est requis.',
|
||||
dateOfBirthRequired: 'La date de naissance est requise.',
|
||||
licenseExpiryRequired: "La date d'expiration du permis est requise.",
|
||||
licenseIssueDateRequired: 'La date de délivrance du permis est requise.',
|
||||
additionalDriverFirstNameRequired: 'Le prénom du conducteur additionnel est requis.',
|
||||
additionalDriverLastNameRequired: 'Le nom du conducteur additionnel est requis.',
|
||||
additionalDriverLicenseRequired: 'Le permis du conducteur additionnel est requis.',
|
||||
},
|
||||
promo: {
|
||||
invalid: 'Code promo invalide ou expiré.',
|
||||
unavailable: 'Impossible de valider le code promo. Réessayez.',
|
||||
},
|
||||
submit: {
|
||||
failed: 'La réservation a échoué. Réessayez.',
|
||||
network: 'Erreur réseau. Vérifiez votre connexion puis réessayez.',
|
||||
},
|
||||
payment: {
|
||||
initiateFailed: 'Le paiement n’a pas pu être démarré.',
|
||||
network: 'Erreur réseau. Réessayez.',
|
||||
},
|
||||
},
|
||||
confirmation: {
|
||||
eyebrow: 'Réservation enregistrée',
|
||||
title: 'Demande de réservation reçue.',
|
||||
description: 'La réservation a été créée en brouillon et sera confirmée par le personnel ou après paiement.',
|
||||
bookingSummary: 'Résumé de réservation',
|
||||
vehicle: 'Véhicule',
|
||||
customer: 'Client',
|
||||
email: 'E-mail',
|
||||
dates: 'Dates',
|
||||
total: 'Total',
|
||||
reference: 'Référence',
|
||||
status: 'Statut',
|
||||
browseMoreVehicles: 'Voir plus de véhicules',
|
||||
backHome: "Retour à l'accueil",
|
||||
},
|
||||
},
|
||||
ar: {
|
||||
lang: 'ar',
|
||||
dir: 'rtl',
|
||||
siteNameFallback: 'موقع الشركة',
|
||||
rentalCompanyFallback: 'شركة تأجير',
|
||||
nav: {
|
||||
about: 'من نحن',
|
||||
vehicles: 'المركبات',
|
||||
offers: 'العروض',
|
||||
pricing: 'الأسعار',
|
||||
blog: 'المدونة',
|
||||
contact: 'اتصل بنا',
|
||||
bookCar: 'احجز سيارة',
|
||||
siteNavigation: 'روابط الموقع',
|
||||
},
|
||||
home: {
|
||||
heroTitleFallback: 'احجز مباشرة مع شركة التأجير.',
|
||||
heroDescription: 'هذا الموقع المخصص للعلامة التجارية يدير الحجز والدفع مباشرة. الاكتشاف عبر المنصة يحول المستأجرين إلى هنا لإتمام الحجز.',
|
||||
viewOffers: 'عرض العروض',
|
||||
contactCompany: 'التواصل مع الشركة',
|
||||
heroImageMissing: 'لم يتم إعداد صورة رئيسية بعد.',
|
||||
activeOffers: 'العروض النشطة',
|
||||
seeAllOffers: 'عرض كل العروض',
|
||||
noActiveOffers: 'لا توجد عروض نشطة حالياً.',
|
||||
publishedVehicles: 'المركبات المنشورة',
|
||||
available: (count) => `${count} متاح`,
|
||||
viewVehicle: 'عرض المركبة',
|
||||
},
|
||||
about: {
|
||||
eyebrow: 'من نحن',
|
||||
defaultTagline: 'تستخدم هذه الشركة RentalDriveGo لنشر المركبات وإدارة الحجوزات وقبول مدفوعات الإيجار المباشرة.',
|
||||
location: 'الموقع',
|
||||
phone: 'الهاتف',
|
||||
email: 'البريد الإلكتروني',
|
||||
whatsapp: 'واتساب',
|
||||
locationMissing: 'لم يتم نشر الموقع بعد',
|
||||
phoneMissing: 'لم يتم نشر الهاتف بعد',
|
||||
emailMissing: 'لم يتم نشر البريد الإلكتروني بعد',
|
||||
whatsappMissing: 'لم يتم نشر واتساب بعد',
|
||||
heroImageMissing: 'أضف صورة رئيسية من إعدادات العلامة التجارية لتخصيص هذه الصفحة.',
|
||||
directBookingTitle: 'حجز مباشر',
|
||||
directBookingBody: 'يقوم المستأجرون بالحجز والدفع مباشرة على موقع الشركة بدلاً من إتمام العملية في المنصة.',
|
||||
publishedFleetTitle: 'أسطول منشور',
|
||||
publishedFleetBody: 'تتم إدارة صور المركبات والأسعار والتوفر من لوحة التحكم الخاصة ونشرها هنا تلقائياً.',
|
||||
marketplaceReadyTitle: 'جاهز للمنصة',
|
||||
marketplaceReadyBody: 'الاكتشاف عبر المنصة يوجه المستأجرين المؤهلين إلى هذا الموقع المخصص لإتمام الحجز والدفع.',
|
||||
companyDetails: 'تفاصيل الشركة',
|
||||
companyAddressMissing: 'لم يتم إعداد عنوان عام بعد.',
|
||||
},
|
||||
contact: {
|
||||
title: 'اتصل بنا',
|
||||
description: 'استخدم بيانات الاتصال العامة للشركة أو وسّع هذه الصفحة لاستدعاء نقطة النهاية `/site/:slug/contact`.',
|
||||
},
|
||||
offers: {
|
||||
title: 'العروض',
|
||||
empty: 'لا توجد عروض متاحة حالياً.',
|
||||
noDescription: 'لا يوجد وصف متاح.',
|
||||
bookOffer: 'احجز بهذا العرض',
|
||||
},
|
||||
pricing: {
|
||||
eyebrow: 'الأسعار',
|
||||
title: 'خطط تناسب نمو عمليات التأجير',
|
||||
description: 'اختر الخطة التي تناسب حجم أسطولك وحجم الحجوزات. يدعم التصميم AmanPay وPayPal إلى جانب الفوترة بعملات MAD وUSD وEUR.',
|
||||
popular: 'الأكثر شيوعاً',
|
||||
acceptedPaymentMethods: 'وسائل الدفع المقبولة',
|
||||
paymentsDescription: 'ادفع بأمان عبر AmanPay أو PayPal. العملات المدعومة: MAD وUSD وEUR.',
|
||||
faq: {
|
||||
paymentMethodsQuestion: 'ما وسائل الدفع المدعومة؟',
|
||||
paymentMethodsAnswer: 'يمكن للشركات استخدام AmanPay أو PayPal للاشتراكات، بينما يدفع المستأجرون مباشرة للشركات عبر الوسائل التي تضبطها الشركة.',
|
||||
connectPaymentsQuestion: 'هل يمكنني البدء قبل ربط وسائل الدفع؟',
|
||||
connectPaymentsAnswer: 'نعم. يمكن للشركات في الفترة التجريبية إعداد العلامة التجارية والأسطول والعروض قبل تفعيل الدفع المباشر.',
|
||||
supportedCurrenciesQuestion: 'ما العملات المدعومة؟',
|
||||
supportedCurrenciesAnswer: 'يدعم التصميم الحالي MAD وUSD وEUR عبر فوترة الاشتراكات وتسعير الإيجارات.',
|
||||
},
|
||||
plans: {
|
||||
starterDescription: 'للأساطيل الصغيرة التي تحتاج إلى حجوزات مباشرة وحضور ويب مخصص للعلامة التجارية.',
|
||||
growthDescription: 'للمشغلين الذين يوسعون التسويق والعروض وتدفقات العمل متعددة الأدوار.',
|
||||
proDescription: 'للمشغلين الأكبر الذين يحتاجون إلى تحكم أعمق بالعلامة التجارية ومرونة تشغيلية أكبر.',
|
||||
starterFeatures: ['موقع حجز مخصص', 'نشر الأسطول', 'الظهور في المنصة', 'وصول أساسي للفريق'],
|
||||
growthFeatures: ['كل ما في Starter', 'عروض مميزة في المنصة', 'قواعد تسعير متقدمة', 'تقارير موسعة'],
|
||||
proFeatures: ['علامة تجارية مخصصة', 'تهيئة أولوية', 'عمليات متقدمة', 'لمسة وايت ليبل'],
|
||||
},
|
||||
},
|
||||
blog: {
|
||||
eyebrow: 'المدونة',
|
||||
title: 'رؤى لمشغلي التأجير',
|
||||
description: 'يتضمن التصميم مساراً قياسياً للمدونة. تملأ هذه الصفحة هذا المسار بمحتوى تمهيدي يمكن استبداله لاحقاً بمحتوى من نظام إدارة محتوى.',
|
||||
draftPlaceholder: 'مسودة مقال',
|
||||
posts: [
|
||||
{
|
||||
title: 'كيف يغير الحجز المباشر هوامش الربح',
|
||||
excerpt: 'نقل المستأجرين من الاكتشاف عبر المنصة إلى تجربة حجز تحمل علامتك يحمي التسعير وعلاقة العميل.',
|
||||
category: 'العمليات',
|
||||
},
|
||||
{
|
||||
title: 'ماذا تنشر في صفحة أسطول حديثة',
|
||||
excerpt: 'قائمة عملية لصور المركبات والمواصفات ووضوح الأسعار وعناصر الثقة التي تحول الزائر إلى حجز.',
|
||||
category: 'التسويق',
|
||||
},
|
||||
{
|
||||
title: 'استخدام العروض دون إفساد نموذج التسعير',
|
||||
excerpt: 'تعمل العروض المميزة بشكل أفضل عندما تكون محددة زمنياً وقابلة للقياس ومرتبطة باستراتيجية أسطول حقيقية.',
|
||||
category: 'الإيرادات',
|
||||
},
|
||||
],
|
||||
},
|
||||
vehicles: {
|
||||
title: 'أسطولنا',
|
||||
available: (count) => `${count} مركبة متاحة`,
|
||||
noVehiclesTitle: 'لا توجد مركبات تطابق عوامل التصفية.',
|
||||
noVehiclesBody: 'حاول تعديل فلاتر الفئة أو السعر أو ناقل الحركة.',
|
||||
clearFilters: 'مسح الفلاتر',
|
||||
noPhoto: 'لا توجد صورة',
|
||||
seats: (count) => `${count} مقاعد`,
|
||||
perDay: 'في اليوم',
|
||||
viewDetails: 'عرض التفاصيل',
|
||||
filters: {
|
||||
title: 'الفلاتر',
|
||||
clearAll: 'مسح الكل',
|
||||
category: 'الفئة',
|
||||
transmission: 'ناقل الحركة',
|
||||
maxPricePerDay: 'الحد الأقصى للسعر / يوم',
|
||||
upTo: 'حتى',
|
||||
},
|
||||
unavailable: {
|
||||
eyebrow: 'الموقع غير متاح',
|
||||
title: 'تفاصيل المركبة غير متاحة مؤقتاً.',
|
||||
body: 'الموقع العام يعمل، لكن قاعدة البيانات المحلية غير متاحة حالياً.',
|
||||
backHome: 'العودة للرئيسية',
|
||||
},
|
||||
detail: {
|
||||
perDay: '/ يوم',
|
||||
bookNow: 'احجز الآن',
|
||||
back: 'رجوع',
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
loadingForm: 'جاري تحميل نموذج الحجز...',
|
||||
eyebrow: 'خطوات الحجز',
|
||||
title: 'أكمل حجزك',
|
||||
steps: {
|
||||
selectDates: 'اختيار التواريخ',
|
||||
driverDetails: 'بيانات السائق',
|
||||
reviewBooking: 'مراجعة الحجز',
|
||||
payment: 'الدفع',
|
||||
},
|
||||
loadingVehicle: 'جاري تحميل المركبة...',
|
||||
vehicleNotFound: 'المركبة غير موجودة.',
|
||||
vehicleLoadError: 'تعذر تحميل تفاصيل المركبة.',
|
||||
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
|
||||
startDate: 'تاريخ البداية',
|
||||
endDate: 'تاريخ النهاية',
|
||||
continueToDriverDetails: 'المتابعة إلى بيانات السائق ->',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
driverLicense: 'رخصة القيادة',
|
||||
nationality: 'الجنسية',
|
||||
dateOfBirth: 'تاريخ الميلاد',
|
||||
licenseIssued: 'تاريخ إصدار الرخصة',
|
||||
licenseExpiry: 'تاريخ انتهاء الرخصة',
|
||||
insuranceAndPickupPolicies: 'التأمين وسياسات الاستلام',
|
||||
insuranceHint: 'اختر حماية اختيارية قبل تأكيد الحجز.',
|
||||
requiredCoverage: 'التغطية المطلوبة',
|
||||
none: 'لا يوجد',
|
||||
noOptionalInsurance: 'لا توجد إضافات تأمين اختيارية متاحة لهذه الشركة.',
|
||||
additionalDriver: 'سائق إضافي',
|
||||
charge: 'الرسوم',
|
||||
promoCode: 'رمز ترويجي',
|
||||
enterCode: 'أدخل الرمز',
|
||||
apply: 'تطبيق',
|
||||
appliedPromo: (title, discount) => `✓ تم تطبيق "${title}" بخصم ${discount}%`,
|
||||
back: '<- رجوع',
|
||||
reviewBookingCta: 'مراجعة الحجز ->',
|
||||
confirmReservation: 'تأكيد الحجز',
|
||||
processing: 'جارٍ المعالجة...',
|
||||
reservationCreated: '✓ تم إنشاء الحجز - اختر طريقة الدفع',
|
||||
manualApprovalRequired: 'مراجعة رخصة القيادة مطلوبة قبل الدفع الإلكتروني. يمكنك التأكيد مع الدفع عند الاستلام أو انتظار موافقة الموظفين.',
|
||||
totalDue: 'المبلغ المستحق',
|
||||
currency: 'العملة',
|
||||
paymentMethod: 'طريقة الدفع',
|
||||
paymentMethods: {
|
||||
amanpay: { title: 'AmanPay', description: 'دفع إلكتروني آمن عبر AmanPay' },
|
||||
paypal: { title: 'PayPal', description: 'ادفع عبر حساب PayPal أو البطاقة' },
|
||||
cash: { title: 'الدفع عند الاستلام', description: 'ادفع نقداً أو بالبطاقة عند استلام المركبة' },
|
||||
},
|
||||
redirectingToPayment: 'جارٍ التحويل إلى الدفع...',
|
||||
confirmPayAtPickup: 'تأكيد - الدفع عند الاستلام',
|
||||
payWith: (provider) => `ادفع عبر ${provider} ->`,
|
||||
summary: {
|
||||
vehicle: 'المركبة',
|
||||
dates: 'التواريخ',
|
||||
primaryDriver: 'السائق الرئيسي',
|
||||
email: 'البريد الإلكتروني',
|
||||
driverLicense: 'رخصة القيادة',
|
||||
licenseExpiry: 'انتهاء الرخصة',
|
||||
additionalDriver: 'سائق إضافي',
|
||||
promoCode: 'الرمز الترويجي',
|
||||
discount: (discount) => `الخصم (${discount}%)`,
|
||||
total: 'الإجمالي',
|
||||
},
|
||||
validation: {
|
||||
pickStartDate: 'اختر تاريخ البداية.',
|
||||
pickEndDate: 'اختر تاريخ النهاية.',
|
||||
endDateAfterStart: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
|
||||
firstNameRequired: 'الاسم الأول مطلوب.',
|
||||
lastNameRequired: 'اسم العائلة مطلوب.',
|
||||
validEmailRequired: 'البريد الإلكتروني الصحيح مطلوب.',
|
||||
driverLicenseRequired: 'رخصة القيادة مطلوبة.',
|
||||
dateOfBirthRequired: 'تاريخ الميلاد مطلوب.',
|
||||
licenseExpiryRequired: 'تاريخ انتهاء الرخصة مطلوب.',
|
||||
licenseIssueDateRequired: 'تاريخ إصدار الرخصة مطلوب.',
|
||||
additionalDriverFirstNameRequired: 'الاسم الأول للسائق الإضافي مطلوب.',
|
||||
additionalDriverLastNameRequired: 'اسم العائلة للسائق الإضافي مطلوب.',
|
||||
additionalDriverLicenseRequired: 'رخصة السائق الإضافي مطلوبة.',
|
||||
},
|
||||
promo: {
|
||||
invalid: 'الرمز الترويجي غير صالح أو منتهي.',
|
||||
unavailable: 'تعذر التحقق من الرمز الترويجي. حاول مرة أخرى.',
|
||||
},
|
||||
submit: {
|
||||
failed: 'فشل الحجز. حاول مرة أخرى.',
|
||||
network: 'خطأ في الشبكة. تحقق من الاتصال ثم حاول مرة أخرى.',
|
||||
},
|
||||
payment: {
|
||||
initiateFailed: 'تعذر بدء عملية الدفع.',
|
||||
network: 'خطأ في الشبكة. حاول مرة أخرى.',
|
||||
},
|
||||
},
|
||||
confirmation: {
|
||||
eyebrow: 'تم حفظ الحجز',
|
||||
title: 'تم استلام طلب الحجز.',
|
||||
description: 'تم إنشاء الحجز بحالة مسودة وسيتم تأكيده من قبل الموظفين أو بعد الدفع.',
|
||||
bookingSummary: 'ملخص الحجز',
|
||||
vehicle: 'المركبة',
|
||||
customer: 'العميل',
|
||||
email: 'البريد الإلكتروني',
|
||||
dates: 'التواريخ',
|
||||
total: 'الإجمالي',
|
||||
reference: 'المرجع',
|
||||
status: 'الحالة',
|
||||
browseMoreVehicles: 'تصفح المزيد من المركبات',
|
||||
backHome: 'العودة للرئيسية',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getPublicSiteDictionary(language: PublicSiteLanguage): PublicSiteDictionary {
|
||||
return dictionaries[language]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const PRODUCTION_DOMAIN = 'rentaldrivego.com'
|
||||
const DEFAULT_SLUG = process.env.NEXT_PUBLIC_COMPANY_SLUG ?? 'demo'
|
||||
|
||||
/**
|
||||
* Resolve the company slug from the request host.
|
||||
*
|
||||
* Production: {slug}.rentaldrivego.com → slug
|
||||
* Development: localhost / 127.0.0.1 → DEFAULT_SLUG (env var fallback)
|
||||
* Staging/preview URLs that don't match the production domain also fall back.
|
||||
*/
|
||||
function resolveSlug(host: string | null): string {
|
||||
if (!host) return DEFAULT_SLUG
|
||||
|
||||
// Strip port number if present
|
||||
const hostname = host.split(':')[0]
|
||||
|
||||
// Production subdomain: slug.rentaldrivego.com
|
||||
if (hostname.endsWith(`.${PRODUCTION_DOMAIN}`)) {
|
||||
const subdomain = hostname.slice(0, hostname.length - PRODUCTION_DOMAIN.length - 1)
|
||||
// Ignore www / bare domain
|
||||
if (subdomain && subdomain !== 'www') return subdomain
|
||||
}
|
||||
|
||||
return DEFAULT_SLUG
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const host = request.headers.get('host')
|
||||
const slug = resolveSlug(host)
|
||||
|
||||
const response = NextResponse.next()
|
||||
|
||||
// Forward the slug as a request header so server components can read it
|
||||
// via `headers()` without needing cookies.
|
||||
response.headers.set('x-company-slug', slug)
|
||||
|
||||
// Also set a short-lived cookie so client components / route handlers
|
||||
// can pick it up without another round-trip.
|
||||
response.cookies.set('x-company-slug', slug, {
|
||||
path: '/',
|
||||
maxAge: 60, // 1 minute – intentionally short; re-derived on every request
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // readable from JS for client-side fetches
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimisation)
|
||||
* - favicon.ico
|
||||
* - public folder files (png, svg, jpg, …)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|otf|css|js)$).*)',
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user