chore: wire Carplace into dev and production stacks
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Carplace application
|
||||
NODE_ENV=production
|
||||
CARPLACE_ASSET_PREFIX=/carplace
|
||||
|
||||
# Server-to-server API connection inside the container network.
|
||||
API_INTERNAL_URL=http://api:4000/api/v1
|
||||
API_URL=http://api:4000
|
||||
|
||||
# Browser requests use /carplace/api/v1 by default.
|
||||
# Enable direct API mode only when CORS and cookie-domain settings are deliberate.
|
||||
NEXT_PUBLIC_CARPLACE_DIRECT_API=false
|
||||
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||
|
||||
# Public dashboard action used by the Carplace header.
|
||||
NEXT_PUBLIC_DASHBOARD_URL=/dashboard
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,37 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
|
||||
const CARPLACE_ASSET_PREFIX = '/carplace'
|
||||
const carplaceAssetPrefix = normalizeAssetPrefix(
|
||||
process.env.CARPLACE_ASSET_PREFIX ?? (process.env.NODE_ENV === 'production' ? CARPLACE_ASSET_PREFIX : undefined),
|
||||
CARPLACE_ASSET_PREFIX,
|
||||
)
|
||||
const securityHeaders = buildSecurityHeaders({ assetSources: [carplaceAssetPrefix] })
|
||||
|
||||
const nextConfig = {
|
||||
...(carplaceAssetPrefix ? { assetPrefix: carplaceAssetPrefix } : {}),
|
||||
images: {
|
||||
remotePatterns: [{ protocol: 'https', hostname: 'res.cloudinary.com' }],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async headers() {
|
||||
return [{ source: '/:path*', headers: securityHeaders }]
|
||||
},
|
||||
async rewrites() {
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://api:4000').replace(/\/api\/v1\/?$/, '')
|
||||
|
||||
return {
|
||||
beforeFiles: [
|
||||
{ source: '/carplace/api/:path*', destination: `${apiOrigin}/api/:path*` },
|
||||
],
|
||||
afterFiles: [
|
||||
{ source: '/carplace', destination: '/' },
|
||||
{ source: '/carplace/:path*', destination: '/:path*' },
|
||||
],
|
||||
fallback: [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@rentaldrivego/carplace",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "next dev -H 0.0.0.0 -p 3004",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "next build",
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "next start -H 0.0.0.0 -p 3004",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rentaldrivego/types": "*",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "^16.2.9",
|
||||
"node-cron": "4.5.0",
|
||||
"nodemailer": "9.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"turbo": "2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#0f172a" />
|
||||
<text
|
||||
x="16"
|
||||
y="21"
|
||||
text-anchor="middle"
|
||||
font-family="Inter, Arial, sans-serif"
|
||||
font-size="18"
|
||||
font-weight="700"
|
||||
fill="#ffffff"
|
||||
>
|
||||
R
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 836 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 902 KiB |
@@ -0,0 +1,102 @@
|
||||
import { BadgeCheck, Building2, CalendarDays, MapPin, MessageCircle, Phone, ShieldCheck, Star } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { formatOfferValue } from '@/lib/carplace/offers'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CompanyBrand, OfferSummary, VehicleSummary } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
interface CompanyProfile {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
brand: (CompanyBrand & Record<string, unknown>) | null
|
||||
vehicles: Array<Omit<VehicleSummary, 'company'>>
|
||||
offers: OfferSummary[]
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
overallRating: number
|
||||
comment?: string | null
|
||||
createdAt: string
|
||||
renter?: { firstName: string; lastName: string }
|
||||
}
|
||||
|
||||
export default async function CarplaceCompanyPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const resolvedParams = await params
|
||||
const { slug } = resolvedParams
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const [company, reviews] = await Promise.all([
|
||||
carplaceFetchOrDefault<CompanyProfile | null>(`/carplace/${encodeURIComponent(slug)}`, null),
|
||||
carplaceFetchOrDefault<Review[]>(`/carplace/${encodeURIComponent(slug)}/reviews`, []),
|
||||
])
|
||||
|
||||
if (!company) {
|
||||
return <main className="site-page"><div className="shell py-20"><section className="mx-auto max-w-2xl rounded-[2rem] border border-stone-200 bg-white p-8 text-center dark:border-blue-900 dark:bg-blue-950"><Building2 className="mx-auto h-12 w-12 text-stone-400" /><h1 className="mt-5 text-3xl font-black text-blue-950 dark:text-white">{copy.status.unavailable}</h1><p className="mt-3 text-stone-600 dark:text-slate-300">{copy.status.unavailableBody}</p><Link href={carplaceHref()} className="carplace-primary-button mt-7 justify-center">{copy.actions.back}</Link></section></div></main>
|
||||
}
|
||||
|
||||
const brand = company.brand
|
||||
const name = brand?.displayName || company.name
|
||||
const vehicles: VehicleSummary[] = company.vehicles.map((vehicle) => ({ ...vehicle, company: { slug: company.slug, brand } }))
|
||||
const description = localizedBrandValue(brand, 'description', language)
|
||||
const policies = [
|
||||
localizedBrandValue(brand, 'depositPolicySummary', language),
|
||||
localizedBrandValue(brand, 'mileagePolicySummary', language),
|
||||
localizedBrandValue(brand, 'cancellationPolicySummary', language),
|
||||
localizedBrandValue(brand, 'requiredDocumentsSummary', language),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="relative overflow-hidden border-b border-stone-200 bg-blue-950 text-white dark:border-blue-900">
|
||||
{typeof brand?.coverImageUrl === 'string' && brand.coverImageUrl ? <div className="absolute inset-0 opacity-30"><img src={brand.coverImageUrl} alt="" className="h-full w-full object-cover" /></div> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-950 via-blue-950/90 to-blue-900/60" />
|
||||
<div className="shell relative py-12 sm:py-16">
|
||||
<nav className="text-sm text-blue-200"><Link href={carplaceHref()} className="text-blue-200 no-underline hover:text-white">Carplace</Link><span className="mx-2">/</span><span>{name}</span></nav>
|
||||
<div className="mt-8 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-300"><BadgeCheck className="h-4 w-4" />{copy.company.partner}</p>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-[-0.04em] sm:text-5xl">{name}</h1>
|
||||
{description ? <p className="mt-5 max-w-2xl text-base leading-8 text-blue-100">{description}</p> : null}
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-blue-100">
|
||||
{brand?.publicCity ? <span className="carplace-dark-pill"><MapPin className="h-4 w-4" />{brand.publicCity}{brand.publicCountry ? `, ${brand.publicCountry}` : ''}</span> : null}
|
||||
<span className="carplace-dark-pill"><CalendarDays className="h-4 w-4" />{vehicles.length} {copy.company.vehicles}</span>
|
||||
{brand?.carplaceRating ? <span className="carplace-dark-pill"><Star className="h-4 w-4 fill-current text-amber-300" />{brand.carplaceRating.toFixed(1)} · {reviews.length} {copy.company.reviews}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{brand?.publicPhone ? <a href={`tel:${brand.publicPhone}`} className="carplace-light-button"><Phone className="h-4 w-4" />{copy.vehicle.phone}</a> : null}
|
||||
{brand?.whatsappNumber ? <a href={`https://wa.me/${brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noreferrer" className="carplace-orange-button"><MessageCircle className="h-4 w-4" />{copy.vehicle.whatsapp}</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shell py-12">
|
||||
{company.offers.length > 0 ? <section><h2 className="text-2xl font-black text-blue-950 dark:text-white">{copy.company.offers}</h2><div className="mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">{company.offers.map((offer) => <article key={offer.id} className="rounded-2xl border border-orange-200 bg-orange-50 p-5 dark:border-orange-900 dark:bg-orange-950/30"><div className="flex items-start justify-between gap-3"><h3 className="font-black text-blue-950 dark:text-white">{offer.title}</h3><span className="rounded-xl bg-orange-600 px-3 py-1.5 text-lg font-black text-white">{formatOfferValue(offer, language)}</span></div><p className="mt-4 text-xs text-stone-500 dark:text-slate-400">{new Intl.DateTimeFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-MA' : 'en-MA', { dateStyle: 'medium' }).format(new Date(offer.validUntil))}</p></article>)}</div></section> : null}
|
||||
|
||||
<section className={company.offers.length > 0 ? 'mt-14' : ''}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">{name}</p><h2 className="mt-2 text-3xl font-black tracking-[-0.035em] text-blue-950 dark:text-white">{copy.company.fleet}</h2></div><Link href={`${carplaceHref('/search')}?pickupLocation=${encodeURIComponent(brand?.publicCity || '')}`} className="carplace-secondary-button">{copy.actions.search}</Link></div>
|
||||
{vehicles.length > 0 ? <div className="mt-7 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{vehicles.map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} />)}</div> : <p className="mt-6 rounded-2xl border border-dashed border-stone-300 p-8 text-center text-stone-500 dark:border-blue-800 dark:text-slate-400">{copy.results.none}</p>}
|
||||
</section>
|
||||
|
||||
<div className="mt-14 grid gap-6 lg:grid-cols-2">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-7 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-xl font-black text-blue-950 dark:text-white"><ShieldCheck className="h-5 w-5 text-emerald-600" />{copy.company.policies}</h2>{policies.length > 0 ? <ul className="mt-5 grid gap-3 text-sm leading-6 text-stone-600 dark:text-slate-300">{policies.map((policy) => <li key={policy} className="rounded-xl bg-stone-50 p-4 dark:bg-blue-900/30">{policy}</li>)}</ul> : <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-slate-400">{copy.vehicle.estimateDisclaimer}</p>}</section>
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-7 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-xl font-black text-blue-950 dark:text-white"><Star className="h-5 w-5 text-amber-500" />{copy.vehicle.rating}</h2>{reviews.length > 0 ? <div className="mt-5 grid gap-4">{reviews.slice(0, 4).map((review) => <blockquote key={review.id} className="rounded-xl bg-stone-50 p-4 dark:bg-blue-900/30"><p className="font-bold text-amber-600">{'★'.repeat(Math.max(1, Math.min(5, review.overallRating)))}</p>{review.comment ? <p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">“{review.comment}”</p> : null}<footer className="mt-2 text-xs text-stone-500">{review.renter ? `${review.renter.firstName} ${review.renter.lastName.slice(0, 1)}.` : copy.vehicle.new}</footer></blockquote>)}</div> : <p className="mt-4 text-sm text-stone-500 dark:text-slate-400">{copy.vehicle.new}</p>}</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function localizedBrandValue(brand: (CompanyBrand & Record<string, unknown>) | null, key: string, language: 'en' | 'fr' | 'ar'): string | null {
|
||||
if (!brand) return null
|
||||
const localized = brand[`${key}${language.toUpperCase()}`]
|
||||
if (typeof localized === 'string' && localized.trim()) return localized.trim()
|
||||
const direct = brand[key]
|
||||
return typeof direct === 'string' && direct.trim() ? direct.trim() : null
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ArrowLeft, BadgeCheck, Fuel, Gauge, MapPin, MessageCircle, Phone, ShieldCheck, Star, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import ProgressiveBookingFlow, { type BookingInitialValues } from '@/components/carplace/ProgressiveBookingFlow'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceCompanyHref, carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CompanyBrand, VehicleSearchResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
interface VehicleDetail {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
seats?: number | null
|
||||
transmission?: string | null
|
||||
fuelType?: string | null
|
||||
features?: string[]
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: { slug: string; name?: string; brand: CompanyBrand | null }
|
||||
}
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export default async function CarplaceVehiclePage({ params, searchParams }: { params: Promise<{ slug: string; id: string }>; searchParams?: Promise<RawSearchParams> }) {
|
||||
const [resolvedParams, rawParams] = await Promise.all([params, searchParams ?? Promise.resolve({})])
|
||||
const { slug, id } = resolvedParams
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const vehicle = await carplaceFetchOrDefault<VehicleDetail | null>(`/carplace/${encodeURIComponent(slug)}/vehicles/${encodeURIComponent(id)}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
return <main className="site-page"><div className="shell py-20"><section className="mx-auto max-w-2xl rounded-[2rem] border border-stone-200 bg-white p-8 text-center dark:border-blue-900 dark:bg-blue-950"><h1 className="text-3xl font-black text-blue-950 dark:text-white">{copy.status.unavailable}</h1><p className="mt-3 text-stone-600 dark:text-slate-300">{copy.status.unavailableBody}</p><Link href={carplaceHref()} className="carplace-primary-button mt-7 justify-center">{copy.actions.back}</Link></section></div></main>
|
||||
}
|
||||
|
||||
const companyName = vehicle.company.brand?.displayName || vehicle.company.name || copy.vehicle.company
|
||||
const companySlug = vehicle.company.brand?.subdomain || vehicle.company.slug || slug
|
||||
const first = vehicle.photos?.[0]
|
||||
const rest = vehicle.photos?.slice(1, 5) ?? []
|
||||
const related = await carplaceFetchOrDefault<VehicleSearchResponse>(`/carplace/search?category=${encodeURIComponent(vehicle.category)}&pageSize=4`, { items: [], pagination: { page: 1, pageSize: 4, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } })
|
||||
const similar = related.items.filter((item) => item.id !== vehicle.id).slice(0, 3)
|
||||
const initial = buildInitial(rawParams)
|
||||
const preserveQuery = buildPreserveQuery(rawParams)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<div className="shell py-8 sm:py-12">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-stone-500 dark:text-slate-400">
|
||||
<Link href={carplaceHref()} className="no-underline hover:text-orange-600">Carplace</Link><span>/</span>
|
||||
<Link href={carplaceCompanyHref(companySlug)} className="no-underline hover:text-orange-600">{companyName}</Link><span>/</span>
|
||||
<span className="font-semibold text-blue-950 dark:text-white">{vehicle.make} {vehicle.model}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mt-7 grid gap-8 lg:grid-cols-[minmax(0,1fr)_410px]">
|
||||
<div className="min-w-0 space-y-7">
|
||||
<section className="grid gap-3 sm:grid-cols-[2fr_1fr]">
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-[2rem] bg-gradient-to-br from-blue-100 to-stone-100 dark:from-blue-900 dark:to-blue-950 sm:row-span-2">
|
||||
{first ? <img src={first} alt={`${vehicle.year} ${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.noPhoto}</div>}
|
||||
</div>
|
||||
{rest.slice(0, 2).map((photo, index) => <div key={`${photo}-${index}`} className="aspect-[16/10] overflow-hidden rounded-2xl bg-stone-100 dark:bg-blue-900"><img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" /></div>)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950 sm:p-8">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">{copy.categories[vehicle.category as keyof typeof copy.categories] ?? vehicle.category}</p><h1 className="mt-2 text-3xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-4xl">{vehicle.make} {vehicle.model}</h1><p className="mt-2 text-stone-500 dark:text-slate-400">{vehicle.year}</p></div>
|
||||
<div className="text-start sm:text-end"><p className="text-xs text-stone-500 dark:text-slate-400">{copy.vehicle.from}</p><p className="text-2xl font-black text-blue-950 dark:text-white">{formatCurrency(vehicle.dailyRate, 'MAD', language)} <span className="text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.perDay}</span></p></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Spec icon={Gauge} label={copy.vehicle.transmission} value={vehicle.transmission} />
|
||||
<Spec icon={Users} label={copy.vehicle.seats} value={vehicle.seats ? String(vehicle.seats) : null} />
|
||||
<Spec icon={Fuel} label={copy.vehicle.fuel} value={vehicle.fuelType} />
|
||||
<Spec icon={ShieldCheck} label={copy.vehicle.category} value={copy.categories[vehicle.category as keyof typeof copy.categories] ?? vehicle.category} />
|
||||
</div>
|
||||
|
||||
{vehicle.features && vehicle.features.length > 0 ? <div className="mt-7 border-t border-stone-200 pt-6 dark:border-blue-900"><h2 className="text-sm font-black text-blue-950 dark:text-white">{copy.vehicle.features}</h2><div className="mt-3 flex flex-wrap gap-2">{vehicle.features.map((feature) => <span key={feature} className="carplace-spec">{feature}</span>)}</div></div> : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 md:grid-cols-2">
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-lg font-black text-blue-950 dark:text-white"><MapPin className="h-5 w-5 text-orange-600" />{copy.vehicle.pickupLocations}</h2><div className="mt-4 flex flex-wrap gap-2">{vehicle.pickupLocations.length > 0 ? vehicle.pickupLocations.map((location) => <span key={location} className="carplace-spec">{location}</span>) : <span className="text-sm text-stone-500 dark:text-slate-400">{vehicle.company.brand?.publicCity || '—'}</span>}</div></div>
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-lg font-black text-blue-950 dark:text-white"><BadgeCheck className="h-5 w-5 text-emerald-600" />{companyName}</h2><div className="mt-4 flex flex-wrap gap-2">{vehicle.company.brand?.carplaceRating ? <span className="carplace-spec"><Star className="h-4 w-4 fill-current text-amber-500" />{vehicle.company.brand.carplaceRating.toFixed(1)}</span> : <span className="carplace-spec">{copy.vehicle.new}</span>}{vehicle.company.brand?.publicCity ? <span className="carplace-spec"><MapPin className="h-4 w-4" />{vehicle.company.brand.publicCity}</span> : null}</div><Link href={`${carplaceCompanyHref(companySlug)}${preserveQuery ? `?${preserveQuery}` : ''}`} className="carplace-secondary-button mt-5">{copy.actions.viewFleet}<ArrowLeft className="h-4 w-4 rotate-180 rtl:rotate-0" /></Link></div>
|
||||
</section>
|
||||
|
||||
{(vehicle.company.brand?.publicPhone || vehicle.company.brand?.whatsappNumber) ? <section className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950"><h2 className="text-lg font-black text-blue-950 dark:text-white">{copy.vehicle.directContact}</h2><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{companyName}</p><div className="mt-5 flex flex-wrap gap-3">{vehicle.company.brand.publicPhone ? <a className="carplace-secondary-button" href={`tel:${vehicle.company.brand.publicPhone}`}><Phone className="h-4 w-4" />{copy.vehicle.phone}</a> : null}{vehicle.company.brand.whatsappNumber ? <a className="carplace-primary-button" href={`https://wa.me/${vehicle.company.brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noreferrer"><MessageCircle className="h-4 w-4" />{copy.vehicle.whatsapp}</a> : null}</div></section> : null}
|
||||
</div>
|
||||
|
||||
<aside className="h-fit lg:sticky lg:top-24">
|
||||
<ProgressiveBookingFlow vehicleId={vehicle.id} companySlug={companySlug} vehicleName={`${vehicle.year} ${vehicle.make} ${vehicle.model}`} companyName={companyName} dailyRate={vehicle.dailyRate} pickupLocations={vehicle.pickupLocations} allowDifferentDropoff={vehicle.allowDifferentDropoff} dropoffLocations={vehicle.dropoffLocations} language={language} copy={copy} initial={initial} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{similar.length > 0 ? <section className="mt-16"><h2 className="text-3xl font-black tracking-[-0.035em] text-blue-950 dark:text-white">{copy.vehicle.similar}</h2><div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{similar.map((item) => <VehicleCard key={item.id} vehicle={item} copy={copy} language={language} query={preserveQuery} />)}</div></section> : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Spec({ icon: Icon, label, value }: { icon: typeof Gauge; label: string; value?: string | null }) { if (!value) return null; return <div className="rounded-2xl bg-stone-50 p-4 dark:bg-blue-900/30"><Icon className="h-5 w-5 text-orange-600 dark:text-orange-400" /><p className="mt-3 text-xs font-semibold text-stone-500 dark:text-slate-400">{label}</p><p className="mt-1 font-black text-blue-950 dark:text-white">{value}</p></div> }
|
||||
|
||||
function buildInitial(params: RawSearchParams): BookingInitialValues { const v = (key: string) => typeof params[key] === 'string' ? params[key] as string : undefined; return { pickupLocation: v('pickupLocation'), dropoffLocation: v('dropoffLocation'), pickupDate: v('pickupDate'), pickupTime: v('pickupTime'), returnDate: v('returnDate'), returnTime: v('returnTime'), promoCode: v('promoCode'), driverAge: v('driverAge') } }
|
||||
function buildPreserveQuery(params: RawSearchParams): string { const query = new URLSearchParams(); for (const key of ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode']) { const raw = params[key]; if (typeof raw === 'string' && raw) query.set(key, raw) } return query.toString() }
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import AppPrivacyEnPage from './app-privacy-en/page'
|
||||
import AppPrivacyFrPage from './app-privacy-fr/page'
|
||||
import AppPrivacyArPage from './app-privacy-ar/page'
|
||||
import AppTermsEnPage from './app-tc-en/page'
|
||||
import AppTermsFrPage from './app-tc-fr/page'
|
||||
import AppTermsArPage from './app-tc-ar/page'
|
||||
|
||||
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
const page = element as React.ReactElement<Record<string, unknown>>
|
||||
expect(page.type).toBe(FooterContentPage)
|
||||
expect(page.props.slug).toBe(slug)
|
||||
expect(page.props.forcedLanguage).toBe(forcedLanguage)
|
||||
}
|
||||
|
||||
describe('carplace static app policy pages', () => {
|
||||
it('binds app privacy pages to explicit locales', () => {
|
||||
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
|
||||
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
|
||||
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
|
||||
})
|
||||
|
||||
it('binds app terms pages to explicit locales and the app terms content slug', () => {
|
||||
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
|
||||
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
|
||||
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyArPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyEnPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyFrPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsArPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsEnPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsFrPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import FooterPage, { generateStaticParams } from './page'
|
||||
import { footerPageSlugs } from '@/lib/footerContent'
|
||||
|
||||
describe('carplace footer route', () => {
|
||||
it('generates one static route per registered footer slug', () => {
|
||||
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
|
||||
})
|
||||
|
||||
it('passes valid slugs through to FooterContentPage', () => {
|
||||
const element = FooterPage({ params: { slug: 'privacy-policy' } })
|
||||
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
expect(element.type).toBe(FooterContentPage)
|
||||
expect(element.props.slug).toBe('privacy-policy')
|
||||
expect(element.props.forcedLanguage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
|
||||
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
|
||||
expect(navigation.notFound).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return footerPageSlugs.map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
export default function FooterPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
const { slug } = params
|
||||
|
||||
if (!isFooterPageSlug(slug)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <FooterContentPage slug={slug} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import CarplaceFooter from '@/components/carplace/CarplaceFooter'
|
||||
import CarplaceHeader from '@/components/carplace/CarplaceHeader'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
const skipLabels = { en: 'Skip to content', fr: 'Aller au contenu', ar: 'الانتقال إلى المحتوى' } as const
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useCarplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col">
|
||||
<a href="#main-content" className="skip-link">{skipLabels[language]}</a>
|
||||
<CarplaceHeader dashboardUrl={dashboardUrl} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<CarplaceFooter dashboardUrl={dashboardUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ArrowRight, BadgeCheck, Building2, CalendarCheck2, CarFront, CircleDollarSign, FileCheck2, MapPin, SearchCheck, ShieldCheck, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import CarplaceSearchForm from '@/components/carplace/CarplaceSearchForm'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { formatOfferValue } from '@/lib/carplace/offers'
|
||||
import { carplaceCompanyHref, carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CarplaceHomeResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
ECONOMY: CircleDollarSign,
|
||||
COMPACT: CarFront,
|
||||
MIDSIZE: CarFront,
|
||||
FULLSIZE: CarFront,
|
||||
SUV: ShieldCheck,
|
||||
LUXURY: Sparkles,
|
||||
VAN: Building2,
|
||||
TRUCK: Building2,
|
||||
} as const
|
||||
|
||||
export default async function CarplaceHomePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const fallback: CarplaceHomeResponse = { cities: [], offers: [], vehicles: [], companies: [] }
|
||||
const home = await carplaceFetchOrDefault<CarplaceHomeResponse>('/carplace/home', fallback)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="carplace-hero site-glow">
|
||||
<div className="shell grid gap-10 py-12 lg:grid-cols-[1.05fr_.95fr] lg:items-center lg:py-20">
|
||||
<div className="max-w-3xl">
|
||||
<p className="site-kicker">{copy.hero.eyebrow}</p>
|
||||
<h1 className="mt-5 text-4xl font-black leading-[1.03] tracking-[-0.045em] text-blue-950 dark:text-white sm:text-6xl">{copy.hero.title}</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg">{copy.hero.body}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3 text-sm font-semibold text-stone-700 dark:text-slate-200">
|
||||
<span className="carplace-trust-pill"><BadgeCheck className="h-4 w-4 text-emerald-600" />{copy.trust[0][0]}</span>
|
||||
<span className="carplace-trust-pill"><CalendarCheck2 className="h-4 w-4 text-blue-700 dark:text-blue-300" />{copy.trust[1][0]}</span>
|
||||
<span className="carplace-trust-pill"><FileCheck2 className="h-4 w-4 text-orange-600" />{copy.trust[2][0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-5 -z-10 rounded-[3rem] bg-gradient-to-br from-orange-300/30 via-transparent to-blue-400/25 blur-2xl" />
|
||||
<CarplaceSearchForm cities={home.cities} copy={copy} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-y border-stone-200 bg-white/80 dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<div className="shell grid gap-px py-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{copy.trust.map(([title, body], index) => {
|
||||
const Icon = [Building2, SearchCheck, BadgeCheck, CircleDollarSign][index] ?? ShieldCheck
|
||||
return <div key={title} className="flex gap-3 px-4 py-4"><span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-blue-50 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200"><Icon className="h-5 w-5" /></span><div><h2 className="text-sm font-black text-blue-950 dark:text-white">{title}</h2><p className="mt-1 text-xs leading-5 text-stone-500 dark:text-slate-400">{body}</p></div></div>
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.cities} />
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{home.cities.length > 0 ? home.cities.slice(0, 16).map((city) => (
|
||||
<Link key={city} href={`${carplaceHref('/search')}?pickupLocation=${encodeURIComponent(city)}`} className="carplace-city-link"><MapPin className="h-4 w-4" />{city}</Link>
|
||||
)) : <p className="text-sm text-stone-500 dark:text-slate-400">{copy.status.unavailableBody}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section pt-0">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.categories} />
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Object.entries(copy.categories).map(([value, label]) => {
|
||||
const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS] ?? CarFront
|
||||
return (
|
||||
<Link key={value} href={`${carplaceHref('/search')}?category=${value}`} className="carplace-category-card">
|
||||
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-blue-50 text-blue-900 dark:bg-blue-900/50 dark:text-blue-100"><Icon className="h-6 w-6" /></span>
|
||||
<span className="mt-5 flex items-center justify-between gap-3 text-lg font-black text-blue-950 dark:text-white">{label}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{home.offers.length > 0 ? (
|
||||
<section id="offers" className="site-section scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.deals} title={copy.sections.offers} />
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{home.offers.slice(0, 6).map((offer) => {
|
||||
const slug = offer.company.brand?.subdomain || offer.company.slug || ''
|
||||
return (
|
||||
<article key={offer.id} className="carplace-offer-card">
|
||||
<div className="flex items-start justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.16em] text-orange-200">{offer.company.brand?.displayName ?? copy.vehicle.company}</p><h3 className="mt-2 text-xl font-black text-white">{offer.title}</h3></div><span className="rounded-2xl bg-white px-3 py-2 text-2xl font-black text-blue-950">{formatOfferValue(offer, language)}</span></div>
|
||||
<p className="mt-6 text-sm text-blue-100">{new Intl.DateTimeFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-MA' : 'en-MA', { dateStyle: 'medium' }).format(new Date(offer.validUntil))}</p>
|
||||
{slug ? <Link href={carplaceCompanyHref(slug)} className="mt-5 inline-flex items-center gap-2 text-sm font-bold text-white no-underline">{copy.actions.viewFleet}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></Link> : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="site-section">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.vehicles} />
|
||||
<Link href={carplaceHref('/search')} className="carplace-secondary-button">{copy.actions.search}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></Link>
|
||||
</div>
|
||||
{home.vehicles.length > 0 ? <div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{home.vehicles.slice(0, 9).map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} />)}</div> : <EmptyState copy={copy.status.unavailableBody} />}
|
||||
</section>
|
||||
|
||||
<section id="companies" className="site-section scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.companies} title={copy.sections.companies} />
|
||||
{home.companies.length > 0 ? (
|
||||
<div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{home.companies.slice(0, 8).map((company) => {
|
||||
const slug = company.brand?.subdomain || company.slug || ''
|
||||
return (
|
||||
<Link key={company.id} href={carplaceCompanyHref(slug)} className="carplace-company-card">
|
||||
<div className="flex items-start justify-between gap-3"><span className="grid h-12 w-12 place-items-center rounded-2xl bg-blue-50 text-blue-900 dark:bg-blue-900/50 dark:text-blue-100"><Building2 className="h-6 w-6" /></span>{company.brand?.carplaceRating ? <span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950/50 dark:text-amber-200">★ {company.brand.carplaceRating.toFixed(1)}</span> : null}</div>
|
||||
<h3 className="mt-5 truncate text-lg font-black text-blue-950 dark:text-white">{company.brand?.displayName || company.name || copy.vehicle.company}</h3>
|
||||
<p className="mt-2 flex items-center gap-2 text-sm text-stone-500 dark:text-slate-400"><MapPin className="h-4 w-4" />{company.brand?.publicCity || copy.company.partner}</p>
|
||||
<p className="mt-4 text-sm font-semibold text-stone-700 dark:text-slate-200">{company._count.vehicles} {copy.company.vehicles}</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : <EmptyState copy={copy.status.unavailableBody} />}
|
||||
</section>
|
||||
|
||||
<section id="how-it-works" className="site-section scroll-mt-24">
|
||||
<div className="rounded-[2.5rem] bg-blue-950 p-7 text-white sm:p-10 lg:p-14">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.how} inverse />
|
||||
<div className="mt-9 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{copy.how.map(([title, body], index) => <article key={title} className="relative rounded-2xl border border-blue-800 bg-blue-900/55 p-5"><span className="grid h-9 w-9 place-items-center rounded-full bg-orange-500 text-sm font-black text-white">{index + 1}</span><h3 className="mt-5 text-lg font-black">{title}</h3><p className="mt-2 text-sm leading-6 text-blue-100">{body}</p></article>)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section grid gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.guidance} />
|
||||
<div className="mt-6 grid gap-4">{copy.guidance.map(([title, body]) => <article key={title} className="carplace-guidance"><ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" /><div><h3 className="font-black text-blue-950 dark:text-white">{title}</h3><p className="mt-1 text-sm leading-6 text-stone-600 dark:text-slate-300">{body}</p></div></article>)}</div>
|
||||
</div>
|
||||
<div id="help" className="scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.help} title={copy.sections.faq} />
|
||||
<div className="mt-6 grid gap-3">{copy.faq.map(([question, answer]) => <details key={question} className="carplace-faq"><summary>{question}</summary><p>{answer}</p></details>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading({ eyebrow, title, inverse = false }: { eyebrow: string; title: string; inverse?: boolean }) {
|
||||
return <div><p className={`text-xs font-bold uppercase tracking-[0.2em] ${inverse ? 'text-orange-300' : 'text-orange-600 dark:text-orange-400'}`}>{eyebrow}</p><h2 className={`mt-3 text-3xl font-black tracking-[-0.035em] sm:text-4xl ${inverse ? 'text-white' : 'text-blue-950 dark:text-white'}`}>{title}</h2></div>
|
||||
}
|
||||
|
||||
function EmptyState({ copy }: { copy: string }) {
|
||||
return <div className="mt-6 rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500 dark:border-blue-800 dark:text-slate-400">{copy}</div>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import RequestSuccessClient from '@/components/carplace/RequestSuccessClient'
|
||||
|
||||
export default async function RequestSuccessPage({ params }: { params: Promise<{ reference: string }> }) {
|
||||
const { reference } = await params
|
||||
return <RequestSuccessClient reference={decodeURIComponent(reference)} />
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { SlidersHorizontal, SearchX } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import CarplaceSearchForm, { type CarplaceSearchValues } from '@/components/carplace/CarplaceSearchForm'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { VehicleSearchResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export default async function CarplaceSearchPage({ searchParams }: { searchParams?: Promise<RawSearchParams> }) {
|
||||
const params = (await searchParams) ?? {}
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const value = (key: string) => typeof params[key] === 'string' ? params[key] as string : ''
|
||||
|
||||
const initial: CarplaceSearchValues = {
|
||||
pickupLocation: value('pickupLocation') || value('city'),
|
||||
dropoffLocation: value('dropoffLocation'),
|
||||
dropoffMode: value('dropoffMode') === 'different' ? 'different' : 'same',
|
||||
pickupDate: value('pickupDate'),
|
||||
pickupTime: value('pickupTime') || '10:00',
|
||||
returnDate: value('returnDate'),
|
||||
returnTime: value('returnTime') || '10:00',
|
||||
driverAge: value('driverAge') || '25',
|
||||
promoCode: value('promoCode'),
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(value('page') || '1') || 1)
|
||||
const pageSize = 18
|
||||
const apiQuery = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (initial.pickupLocation) { apiQuery.set('city', initial.pickupLocation); apiQuery.set('pickupLocation', initial.pickupLocation) }
|
||||
if (initial.dropoffMode) apiQuery.set('dropoffMode', initial.dropoffMode)
|
||||
if (initial.dropoffMode === 'different' && initial.dropoffLocation) apiQuery.set('dropoffLocation', initial.dropoffLocation)
|
||||
const startDate = toIso(initial.pickupDate, initial.pickupTime)
|
||||
const endDate = toIso(initial.returnDate, initial.returnTime)
|
||||
if (startDate) apiQuery.set('startDate', startDate)
|
||||
if (endDate) apiQuery.set('endDate', endDate)
|
||||
for (const key of ['category', 'transmission', 'make', 'model'] as const) if (value(key)) apiQuery.set(key, value(key))
|
||||
const maxPriceMad = Number(value('maxPrice'))
|
||||
if (Number.isFinite(maxPriceMad) && maxPriceMad > 0) apiQuery.set('maxPrice', String(Math.round(maxPriceMad * 100)))
|
||||
|
||||
const fallback: VehicleSearchResponse = { items: [], pagination: { page, pageSize, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } }
|
||||
const [cities, response] = await Promise.all([
|
||||
carplaceFetchOrDefault<string[]>('/carplace/cities', []),
|
||||
carplaceFetchOrDefault<VehicleSearchResponse>(`/carplace/search?${apiQuery.toString()}`, fallback),
|
||||
])
|
||||
|
||||
const sort = value('sort') || 'price-asc'
|
||||
const items = [...response.items].sort((a, b) => sort === 'price-desc' ? b.dailyRate - a.dailyRate : a.dailyRate - b.dailyRate)
|
||||
const cardQuery = buildCardQuery(params)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="border-b border-stone-200 bg-white/85 py-6 dark:border-blue-900 dark:bg-blue-950/80">
|
||||
<div className="shell"><CarplaceSearchForm cities={cities} copy={copy} initial={initial} compact /></div>
|
||||
</section>
|
||||
|
||||
<div className="shell grid gap-8 py-10 lg:grid-cols-[270px_1fr]">
|
||||
<aside className="h-fit rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950 lg:sticky lg:top-24">
|
||||
<h2 className="flex items-center gap-2 text-lg font-black text-blue-950 dark:text-white"><SlidersHorizontal className="h-5 w-5" />{copy.sections.filters}</h2>
|
||||
<form action={carplaceHref('/search')} method="get" className="mt-5 grid gap-4">
|
||||
{preservedTripInputs(params)}
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.vehicle.category}<select name="category" defaultValue={value('category')} className="carplace-input"><option value="">{copy.results.allCategories}</option>{Object.entries(copy.categories).map(([category, label]) => <option key={category} value={category}>{label}</option>)}</select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.maxPrice}<input type="number" min="0" name="maxPrice" defaultValue={value('maxPrice')} className="carplace-input" inputMode="numeric" /></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.transmission}<select name="transmission" defaultValue={value('transmission')} className="carplace-input"><option value="">—</option>{response.facets.transmissions.map((facet) => <option key={facet.value} value={facet.value}>{facet.value} ({facet.count})</option>)}</select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.make}<input name="make" defaultValue={value('make')} className="carplace-input" list="carplace-makes" /><datalist id="carplace-makes">{response.facets.makes.map((facet) => <option key={facet.value} value={facet.value} />)}</datalist></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.model}<input name="model" defaultValue={value('model')} className="carplace-input" /></label>
|
||||
<button className="carplace-primary-button justify-center" type="submit">{copy.results.apply}</button>
|
||||
<Link href={carplaceHref('/search')} className="carplace-secondary-button justify-center">{copy.results.clear}</Link>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">Carplace</p><h1 className="mt-2 text-3xl font-black tracking-[-0.035em] text-blue-950 dark:text-white">{copy.results.title}</h1><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{response.pagination.totalItems} {copy.results.count}</p></div>
|
||||
<form action={carplaceHref('/search')} method="get" className="flex items-end gap-2">
|
||||
{Object.entries(params).flatMap(([key, raw]) => key === 'sort' || key === 'page' ? [] : (Array.isArray(raw) ? raw : [raw]).filter((entry): entry is string => typeof entry === 'string').map((entry) => <input key={`${key}-${entry}`} type="hidden" name={key} value={entry} />))}
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.sort}<select name="sort" defaultValue={sort} className="carplace-input"><option value="price-asc">{copy.results.sortPriceAsc}</option><option value="price-desc">{copy.results.sortPriceDesc}</option></select></label>
|
||||
<button type="submit" className="carplace-secondary-button">{copy.results.apply}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? <div className="mt-7 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{items.map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} query={cardQuery} />)}</div> : <div className="mt-8 rounded-[2rem] border border-dashed border-stone-300 p-10 text-center dark:border-blue-800"><SearchX className="mx-auto h-10 w-10 text-stone-400" /><h2 className="mt-4 text-xl font-black text-blue-950 dark:text-white">{copy.results.none}</h2><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{copy.results.noneBody}</p></div>}
|
||||
|
||||
{response.pagination.totalPages > 1 ? <Pagination current={response.pagination.page} total={response.pagination.totalPages} params={params} previous={copy.results.previous} next={copy.results.next} label={copy.accessibility.pagination} /> : null}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function toIso(date?: string, time?: string): string | null { if (!date || !time) return null; const result = new Date(`${date}T${time}:00`); return Number.isNaN(result.getTime()) ? null : result.toISOString() }
|
||||
|
||||
function buildCardQuery(params: RawSearchParams): URLSearchParams {
|
||||
const query = new URLSearchParams()
|
||||
for (const key of ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode'] as const) {
|
||||
const raw = params[key]
|
||||
if (typeof raw === 'string' && raw) query.set(key, raw)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function preservedTripInputs(params: RawSearchParams) {
|
||||
return ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode'].flatMap((key) => {
|
||||
const raw = params[key]
|
||||
return (Array.isArray(raw) ? raw : [raw]).filter((entry): entry is string => typeof entry === 'string').map((entry) => <input key={`${key}-${entry}`} type="hidden" name={key} value={entry} />)
|
||||
})
|
||||
}
|
||||
|
||||
function Pagination({ current, total, params, previous, next, label }: { current: number; total: number; params: RawSearchParams; previous: string; next: string; label: string }) {
|
||||
const href = (page: number) => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, raw]) => { if (key === 'page') return; (Array.isArray(raw) ? raw : [raw]).forEach((entry) => { if (typeof entry === 'string') query.append(key, entry) }) }); query.set('page', String(page)); return `${carplaceHref('/search')}?${query.toString()}` }
|
||||
return <nav className="mt-10 flex items-center justify-center gap-3" aria-label={label}>{current > 1 ? <Link href={href(current - 1)} className="carplace-secondary-button">{previous}</Link> : null}<span className="text-sm font-bold text-stone-600 dark:text-slate-300">{current} / {total}</span>{current < total ? <Link href={href(current + 1)} className="carplace-secondary-button">{next}</Link> : null}</nav>
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #fafafa 28%, #f5f5f5 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
.site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
html.dark .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(30, 64, 175, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
.site-section {
|
||||
@apply shell py-10 sm:py-14 lg:py-16;
|
||||
}
|
||||
|
||||
.site-panel {
|
||||
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .site-panel {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-image: linear-gradient(160deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-image: linear-gradient(160deg, #172554 0%, #1b3068 100%);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
|
||||
border-color: rgb(20 45 95 / 0.8);
|
||||
background-color: #172554;
|
||||
}
|
||||
|
||||
html.dark .site-panel-contrast {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
|
||||
}
|
||||
|
||||
.site-lead {
|
||||
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
|
||||
}
|
||||
|
||||
.site-link-primary {
|
||||
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.site-link-secondary {
|
||||
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-[#2e50f1] dark:bg-[#2e50f1]/30 dark:text-[#1e3de0] dark:hover:border-[#1e3de0] dark:hover:text-white;
|
||||
}
|
||||
|
||||
/* Carplace public experience */
|
||||
html {
|
||||
min-width: 320px;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 96px;
|
||||
}
|
||||
|
||||
html[lang='ar'] body {
|
||||
font-family: 'Noto Sans Arabic', Tahoma, Arial, sans-serif;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset-block-start: .5rem;
|
||||
inset-inline-start: .5rem;
|
||||
transform: translateY(-160%);
|
||||
border-radius: .75rem;
|
||||
background: #172554;
|
||||
color: white;
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 800;
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.carplace-header {
|
||||
position: sticky;
|
||||
z-index: 100;
|
||||
inset-block-start: 0;
|
||||
border-bottom: 1px solid rgb(231 229 228 / .9);
|
||||
background: rgb(255 255 255 / .94);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / .06);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
html.dark .carplace-header {
|
||||
border-color: rgb(30 64 175 / .55);
|
||||
background: rgb(23 37 84 / .94);
|
||||
}
|
||||
|
||||
.carplace-control,
|
||||
.carplace-icon-button,
|
||||
.carplace-company-link,
|
||||
.carplace-primary-button,
|
||||
.carplace-secondary-button,
|
||||
.carplace-light-button,
|
||||
.carplace-orange-button {
|
||||
@apply inline-flex min-h-11 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-bold no-underline transition disabled:cursor-not-allowed disabled:opacity-60;
|
||||
}
|
||||
|
||||
.carplace-control,
|
||||
.carplace-icon-button,
|
||||
.carplace-secondary-button {
|
||||
@apply border border-stone-300 bg-white text-stone-700 hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-slate-200 dark:hover:border-blue-500 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.carplace-icon-button { @apply min-w-11 justify-center px-0; }
|
||||
.carplace-company-link { @apply bg-blue-950 text-white hover:bg-blue-900 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-primary-button,
|
||||
.carplace-orange-button { @apply bg-orange-600 text-white hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-light-button { @apply bg-white text-blue-950 hover:bg-blue-50; }
|
||||
|
||||
.carplace-menu {
|
||||
@apply absolute top-full z-50 mt-2 w-60 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1 shadow-2xl dark:border-blue-800 dark:bg-blue-950;
|
||||
}
|
||||
|
||||
.carplace-menu-item { @apply flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-start text-sm font-semibold text-stone-700 hover:bg-blue-50 dark:text-slate-200 dark:hover:bg-blue-900/50; }
|
||||
|
||||
.carplace-hero {
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 10%, rgb(249 115 22 / .16), transparent 28%),
|
||||
radial-gradient(circle at 92% 8%, rgb(37 99 235 / .14), transparent 28%),
|
||||
linear-gradient(180deg, #fff, #f8fafc);
|
||||
}
|
||||
|
||||
html.dark .carplace-hero {
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 10%, rgb(249 115 22 / .18), transparent 28%),
|
||||
radial-gradient(circle at 92% 8%, rgb(59 130 246 / .16), transparent 28%),
|
||||
linear-gradient(180deg, #172554, #0f1a40);
|
||||
}
|
||||
|
||||
.carplace-search {
|
||||
@apply rounded-[2rem] border border-stone-200 bg-white/95 p-5 shadow-[0_30px_90px_rgba(15,23,42,0.14)] backdrop-blur dark:border-blue-800 dark:bg-blue-950/95 sm:p-6;
|
||||
}
|
||||
|
||||
.carplace-search-compact {
|
||||
@apply rounded-2xl p-4 shadow-none;
|
||||
}
|
||||
|
||||
.carplace-input {
|
||||
@apply min-h-11 w-full rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm font-medium text-blue-950 outline-none transition placeholder:text-stone-400 hover:border-blue-500 focus:border-blue-700 focus:ring-2 focus:ring-blue-200 dark:border-blue-800 dark:bg-blue-950 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-900;
|
||||
}
|
||||
|
||||
.carplace-choice { @apply cursor-pointer rounded-full border border-stone-300 bg-white px-3 py-2 text-xs font-bold text-stone-600 transition dark:border-blue-800 dark:bg-blue-950 dark:text-slate-300; }
|
||||
.carplace-choice-active { @apply border-orange-500 bg-orange-50 text-orange-800 dark:bg-orange-950/40 dark:text-orange-200; }
|
||||
.carplace-trust-pill { @apply inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white/85 px-3 py-2 shadow-sm dark:border-blue-800 dark:bg-blue-950/70; }
|
||||
.carplace-dark-pill { @apply inline-flex items-center gap-2 rounded-full border border-blue-700 bg-blue-900/70 px-3 py-2; }
|
||||
.carplace-spec { @apply inline-flex items-center gap-1.5 rounded-full bg-stone-100 px-3 py-1.5 dark:bg-blue-900/45; }
|
||||
|
||||
.carplace-city-link { @apply inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-4 py-2.5 text-sm font-bold text-stone-700 no-underline shadow-sm transition hover:-translate-y-0.5 hover:border-orange-400 hover:text-orange-700 dark:border-blue-800 dark:bg-blue-950 dark:text-slate-200 dark:hover:border-orange-500 dark:hover:text-orange-300; }
|
||||
.carplace-category-card,
|
||||
.carplace-company-card { @apply rounded-[1.5rem] border border-stone-200 bg-white p-5 no-underline shadow-[0_14px_45px_rgba(15,23,42,0.06)] transition hover:-translate-y-1 hover:border-blue-300 hover:shadow-[0_20px_60px_rgba(15,23,42,0.12)] dark:border-blue-900 dark:bg-blue-950 dark:hover:border-blue-600; }
|
||||
.carplace-offer-card { @apply rounded-[2rem] bg-gradient-to-br from-blue-950 to-blue-800 p-6 shadow-[0_24px_70px_rgba(15,23,42,0.2)]; }
|
||||
.carplace-guidance { @apply flex gap-3 rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950; }
|
||||
.carplace-faq { @apply rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950; }
|
||||
.carplace-faq summary { @apply cursor-pointer font-black text-blue-950 dark:text-white; }
|
||||
.carplace-faq p { @apply mb-0 mt-3 text-sm leading-7 text-stone-600 dark:text-slate-300; }
|
||||
|
||||
.carplace-vehicle-card { @apply flex min-w-0 flex-col overflow-hidden rounded-[1.75rem] border border-stone-200 bg-white shadow-[0_18px_60px_rgba(15,23,42,0.08)] transition hover:-translate-y-1 hover:border-blue-300 hover:shadow-[0_24px_70px_rgba(15,23,42,0.14)] dark:border-blue-900 dark:bg-blue-950 dark:hover:border-blue-600; }
|
||||
.carplace-card-action { @apply grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-blue-950 text-white no-underline transition hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-booking { @apply rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_70px_rgba(15,23,42,0.12)] dark:border-blue-800 dark:bg-blue-950; }
|
||||
.carplace-step { @apply flex min-w-0 flex-col items-center gap-1 text-center text-[10px] font-bold text-stone-400; }
|
||||
.carplace-step span { @apply grid h-7 w-7 place-items-center rounded-full border border-stone-300 bg-white dark:border-blue-800 dark:bg-blue-950; }
|
||||
.carplace-step-active { @apply text-orange-700 dark:text-orange-300; }
|
||||
.carplace-step-active span { @apply border-orange-500 bg-orange-50 dark:bg-orange-950/50; }
|
||||
.carplace-step-done { @apply text-emerald-700 dark:text-emerald-300; }
|
||||
.carplace-step-done span { @apply border-emerald-500 bg-emerald-50 dark:bg-emerald-950/50; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import CarplaceShell from '@/components/CarplaceShell'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
return {
|
||||
title: `${copy.brand} by RentalDriveGo`,
|
||||
description: copy.hero.body,
|
||||
alternates: { canonical: '/carplace' },
|
||||
openGraph: {
|
||||
title: `${copy.brand} by RentalDriveGo`,
|
||||
description: copy.hero.body,
|
||||
type: 'website',
|
||||
locale: language === 'ar' ? 'ar_MA' : language === 'fr' ? 'fr_MA' : 'en_MA',
|
||||
siteName: 'RentalDriveGo',
|
||||
},
|
||||
icons: {
|
||||
icon: '/rentaldrivego.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentaldrivego.png',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):localStorage.getItem('rentaldrivego-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<CarplaceShell initialLanguage={language} initialTheme={theme}>{children}</CarplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getFooterContent, localeOptions } from './CarplaceShell'
|
||||
|
||||
describe('CarplaceShell footer content registry', () => {
|
||||
it('exposes exactly the supported carplace locales in selector order', () => {
|
||||
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
|
||||
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
|
||||
const content = getFooterContent('en')
|
||||
|
||||
expect(content.localeLabel).toBe('Global (English)')
|
||||
expect(content.rightsLabel).toBe('All rights reserved.')
|
||||
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/carplace/app-privacy-en' })
|
||||
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/carplace/footer/contact-sales' })
|
||||
})
|
||||
|
||||
it('returns French labels while preserving canonical footer slugs', () => {
|
||||
const content = getFooterContent('fr')
|
||||
|
||||
expect(content.localeLabel).toBe('Europe (French)')
|
||||
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/carplace/footer/terms-of-service' })
|
||||
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/carplace/footer/general-conditions' })
|
||||
})
|
||||
|
||||
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
|
||||
const content = getFooterContent('ar')
|
||||
|
||||
expect(content.localeLabel).toBe('North Africa (Arabic)')
|
||||
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
|
||||
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/carplace/app-privacy-ar' })
|
||||
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { appPrivacyHref, appTermsHref, footerPageHref } from '@/lib/footerContent'
|
||||
import { isCarplaceLanguage, type CarplaceLanguage } from '@/lib/i18n'
|
||||
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
export const localeOptions: Array<{ value: CarplaceLanguage; label: string; flag: string }> = [
|
||||
{ value: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ value: 'ar', label: 'العربية', flag: '🇲🇦' },
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
]
|
||||
|
||||
type Dictionary = {
|
||||
home: string
|
||||
carplace: string
|
||||
signIn: string
|
||||
ownerSignIn: string
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
preferences: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<CarplaceLanguage, Dictionary> = {
|
||||
en: {
|
||||
home: 'Home',
|
||||
carplace: 'Carplace',
|
||||
signIn: 'Sign in',
|
||||
ownerSignIn: 'Create Agency Space',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
preferences: 'Carplace preferences',
|
||||
},
|
||||
fr: {
|
||||
home: 'Accueil',
|
||||
carplace: 'Carplace',
|
||||
signIn: 'Connexion',
|
||||
ownerSignIn: "Créer un espace d'agence",
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
preferences: 'Préférences Carplace',
|
||||
},
|
||||
ar: {
|
||||
home: 'الرئيسية',
|
||||
carplace: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
ownerSignIn: 'إنشاء مساحة الوكالة',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
preferences: 'تفضيلات Carplace',
|
||||
},
|
||||
}
|
||||
|
||||
type FooterContent = {
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
primary: Array<{ label: string; href: string }>
|
||||
secondary: Array<{ label: string; href: string }>
|
||||
}
|
||||
|
||||
const footerLabels: Record<CarplaceLanguage, FooterContent> = {
|
||||
en: {
|
||||
localeLabel: 'Global (English)',
|
||||
rightsLabel: 'All rights reserved.',
|
||||
primary: [
|
||||
{ label: 'Privacy Policy', href: appPrivacyHref.en },
|
||||
{ label: 'Terms of Use', href: appTermsHref.en },
|
||||
{ label: 'Security', href: footerPageHref.security },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'About Us', href: footerPageHref['about-us'] },
|
||||
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
localeLabel: 'Europe (French)',
|
||||
rightsLabel: 'Tous droits réservés.',
|
||||
primary: [
|
||||
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
|
||||
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
|
||||
{ label: 'Sécurité', href: footerPageHref.security },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'À propos', href: footerPageHref['about-us'] },
|
||||
{ label: 'Contact commercial', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
localeLabel: 'North Africa (Arabic)',
|
||||
rightsLabel: 'جميع الحقوق محفوظة.',
|
||||
primary: [
|
||||
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
|
||||
{ label: 'شروط الاستخدام', href: appTermsHref.ar },
|
||||
{ label: 'الأمان', href: footerPageHref.security },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'من نحن', href: footerPageHref['about-us'] },
|
||||
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function getFooterContent(language: CarplaceLanguage): FooterContent {
|
||||
return footerLabels[language]
|
||||
}
|
||||
|
||||
function detectBrowserLanguage(): CarplaceLanguage | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
for (const language of Array.from(navigator.languages ?? [navigator.language])) {
|
||||
const code = language.split('-')[0].toLowerCase()
|
||||
if (isCarplaceLanguage(code)) return code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type PreferencesContextValue = {
|
||||
language: CarplaceLanguage
|
||||
theme: Theme
|
||||
dict: Dictionary
|
||||
setLanguage: (language: CarplaceLanguage) => void
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
||||
|
||||
export function useCarplacePreferences() {
|
||||
const context = useContext(PreferencesContext)
|
||||
if (!context) throw new Error('useCarplacePreferences must be used within CarplaceShell')
|
||||
return context
|
||||
}
|
||||
|
||||
export default function CarplaceShell({
|
||||
children,
|
||||
initialLanguage = 'en',
|
||||
initialTheme = 'light',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
initialLanguage?: CarplaceLanguage
|
||||
initialTheme?: Theme
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [language, setLanguageState] = useState<CarplaceLanguage>(initialLanguage)
|
||||
const [theme, setThemeState] = useState<Theme>(initialTheme)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const previousLanguage = useRef<CarplaceLanguage>(initialLanguage)
|
||||
|
||||
function applyLanguage(nextLanguage: CarplaceLanguage) {
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.lang = nextLanguage
|
||||
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage)
|
||||
}
|
||||
setLanguageState(nextLanguage)
|
||||
}
|
||||
|
||||
function applyTheme(nextTheme: Theme) {
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
|
||||
document.documentElement.style.colorScheme = nextTheme
|
||||
document.body.dataset.theme = nextTheme
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme)
|
||||
}
|
||||
setThemeState(nextTheme)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY)
|
||||
const resolvedLanguage = isCarplaceLanguage(storedLanguage) ? storedLanguage : detectBrowserLanguage()
|
||||
if (resolvedLanguage) setLanguageState(resolvedLanguage)
|
||||
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY)
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') setThemeState(storedTheme)
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
if (!hydrated) return
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language)
|
||||
if (previousLanguage.current !== language) router.refresh()
|
||||
previousLanguage.current = language
|
||||
}, [hydrated, language, pathname, router])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.body.dataset.theme = theme
|
||||
if (hydrated) writeScopedPreference(SHARED_THEME_KEY, theme)
|
||||
}, [hydrated, theme])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, theme, dict: dictionaries[language], setLanguage: applyLanguage, setTheme: applyTheme }),
|
||||
[language, theme],
|
||||
)
|
||||
|
||||
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
|
||||
|
||||
vi.mock('@/components/CarplaceShell', () => ({
|
||||
useCarplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
|
||||
}))
|
||||
|
||||
import FooterContentPage from './FooterContentPage'
|
||||
|
||||
function collectText(node: unknown): string[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
|
||||
if (Array.isArray(node)) return node.flatMap(collectText)
|
||||
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
|
||||
return []
|
||||
}
|
||||
|
||||
function collectElements(node: unknown): React.ReactElement<Record<string, unknown>>[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (Array.isArray(node)) return node.flatMap(collectElements)
|
||||
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
|
||||
return [node, ...collectElements(node.props.children)]
|
||||
}
|
||||
|
||||
describe('FooterContentPage', () => {
|
||||
beforeEach(() => {
|
||||
preferenceState.language = 'en'
|
||||
})
|
||||
|
||||
it('uses the current carplace language when no forced language is provided', () => {
|
||||
preferenceState.language = 'fr'
|
||||
const page = FooterContentPage({ slug: 'contact-sales' })
|
||||
const text = collectText(page).join(' ')
|
||||
|
||||
expect(text).toContain('Informations du pied de page')
|
||||
expect(text).toContain('Besoin de plus de détails')
|
||||
})
|
||||
|
||||
it('lets static app policy pages force their own locale', () => {
|
||||
preferenceState.language = 'en'
|
||||
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
|
||||
const text = collectText(page).join(' ')
|
||||
|
||||
expect(text).toContain('Informations du pied de page')
|
||||
expect(text).not.toContain('Footer information')
|
||||
})
|
||||
|
||||
it('marks Arabic content as right-aligned', () => {
|
||||
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
|
||||
const elements = collectElements(page)
|
||||
|
||||
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
|
||||
})
|
||||
|
||||
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
|
||||
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
|
||||
const links = collectElements(page).filter((element) => element.type === 'a')
|
||||
|
||||
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
|
||||
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
|
||||
import { type CarplaceLanguage } from '@/lib/i18n'
|
||||
|
||||
const pageMeta = {
|
||||
en: {
|
||||
kicker: 'Footer information',
|
||||
cta: 'Need more details?',
|
||||
support: 'Contact the RentalDriveGo team through our official channels for business, support, or partnership requests.',
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Informations du pied de page',
|
||||
cta: 'Besoin de plus de détails ?',
|
||||
support: "Contactez l'équipe RentalDriveGo via nos canaux officiels pour toute demande commerciale, support ou partenariat.",
|
||||
},
|
||||
ar: {
|
||||
kicker: 'معلومات التذييل',
|
||||
cta: 'هل تحتاج إلى مزيد من التفاصيل؟',
|
||||
support: 'تواصل مع فريق RentalDriveGo عبر القنوات الرسمية للاستفسارات التجارية أو الدعم أو الشراكات.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/g
|
||||
|
||||
function renderParagraphText(paragraph: string) {
|
||||
const parts = paragraph.split(urlPattern)
|
||||
return parts.map((part, index) => {
|
||||
if (urlPattern.test(part)) {
|
||||
urlPattern.lastIndex = 0
|
||||
const match = part.match(/^(https?:\/\/[^\s]+?)([.,!?;:]*)$/)
|
||||
const href = match?.[1] ?? part
|
||||
const trailing = match?.[2] ?? ''
|
||||
|
||||
return (
|
||||
<span key={`${part}-${index}`}>
|
||||
<a
|
||||
href={href}
|
||||
className="text-orange-700 underline decoration-orange-300 underline-offset-4 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
{trailing}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
urlPattern.lastIndex = 0
|
||||
return <span key={`${part}-${index}`}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: CarplaceLanguage }) {
|
||||
const { language } = useCarplacePreferences()
|
||||
const activeLanguage = forcedLanguage ?? language
|
||||
const content = getFooterPageContent(activeLanguage, slug)
|
||||
const meta = pageMeta[activeLanguage]
|
||||
const isArabic = activeLanguage === 'ar'
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section mx-auto max-w-4xl">
|
||||
<section className="site-panel overflow-hidden p-0">
|
||||
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
|
||||
<p className="site-kicker">
|
||||
{meta.kicker}
|
||||
</p>
|
||||
<h1 className="site-title">
|
||||
{content.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
|
||||
{content.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
|
||||
{renderParagraphText(paragraph)}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{content.sections?.map((section) => (
|
||||
<section key={section.heading} className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
|
||||
{section.heading}
|
||||
</h2>
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
|
||||
{renderParagraphText(paragraph)}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
|
||||
{meta.cta}
|
||||
</p>
|
||||
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref, homepageHref } from '@/lib/carplace/routes'
|
||||
import { appPrivacyHref, appTermsHref } from '@/lib/footerContent'
|
||||
|
||||
export default function CarplaceFooter({ dashboardUrl }: { dashboardUrl: string }) {
|
||||
const { language } = useCarplacePreferences()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const year = new Date().getUTCFullYear()
|
||||
|
||||
return (
|
||||
<footer className="border-t border-stone-200 bg-white dark:border-blue-900 dark:bg-blue-950">
|
||||
<div className="shell grid gap-10 py-12 md:grid-cols-[1.4fr_1fr_1fr_1fr]">
|
||||
<div>
|
||||
<Link href={homepageHref(language)} className="inline-flex items-center gap-3 text-blue-950 no-underline dark:text-white">
|
||||
<Image src="/rentaldrivego.png" alt="" width={42} height={42} className="rounded-xl" unoptimized />
|
||||
<span><strong className="block text-lg">RentalDriveGo</strong><span className="text-xs font-bold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-400">Carplace</span></span>
|
||||
</Link>
|
||||
<p className="mt-5 max-w-md text-sm leading-7 text-stone-600 dark:text-slate-300">{copy.footer.description}</p>
|
||||
</div>
|
||||
<FooterGroup title={copy.footer.explore} items={[
|
||||
[copy.nav.find, carplaceHref('/search')],
|
||||
[copy.nav.companies, carplaceHref('/#companies')],
|
||||
[copy.nav.deals, carplaceHref('/#offers')],
|
||||
[copy.nav.how, carplaceHref('/#how-it-works')],
|
||||
]} />
|
||||
<FooterGroup title={copy.footer.support} items={[
|
||||
[copy.nav.help, carplaceHref('/#help')],
|
||||
[copy.booking.privacy, appPrivacyHref[language]],
|
||||
[copy.booking.terms, appTermsHref[language]],
|
||||
]} />
|
||||
<FooterGroup title={copy.footer.business} items={[
|
||||
[copy.actions.companySpace, dashboardUrl],
|
||||
['RentalDriveGo', homepageHref(language)],
|
||||
]} external />
|
||||
</div>
|
||||
<div className="border-t border-stone-200 dark:border-blue-900">
|
||||
<div className="shell flex flex-col gap-2 py-5 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between dark:text-slate-400">
|
||||
<p>© {year} RentalDriveGo. {copy.footer.rights}</p>
|
||||
<p>{copy.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterGroup({ title, items, external = false }: { title: string; items: Array<[string, string]>; external?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-sm font-black text-blue-950 dark:text-white">{title}</h2>
|
||||
<ul className="mt-4 grid gap-3 text-sm text-stone-600 dark:text-slate-300">
|
||||
{items.map(([label, href]) => (
|
||||
<li key={`${label}-${href}`}>
|
||||
{external ? <a href={href} className="no-underline hover:text-orange-600">{label}</a> : <Link href={href} className="no-underline hover:text-orange-600">{label}</Link>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { Building2, ChevronDown, Menu, Moon, Search, Sun, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCarplacePreferences, localeOptions } from '@/components/CarplaceShell'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref, homepageHref } from '@/lib/carplace/routes'
|
||||
|
||||
export default function CarplaceHeader({ dashboardUrl }: { dashboardUrl: string }) {
|
||||
const { language, theme, setLanguage, setTheme } = useCarplacePreferences()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const pathname = usePathname()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [languageOpen, setLanguageOpen] = useState(false)
|
||||
const languageRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
function closeMenus(event: MouseEvent) {
|
||||
if (!languageRef.current?.contains(event.target as Node)) setLanguageOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', closeMenus)
|
||||
return () => document.removeEventListener('mousedown', closeMenus)
|
||||
}, [])
|
||||
|
||||
const nav: Array<{ label: string; href: string; icon: typeof Search | null }> = [
|
||||
{ label: copy.nav.find, href: carplaceHref('/search'), icon: Search },
|
||||
{ label: copy.nav.companies, href: carplaceHref('/#companies'), icon: null },
|
||||
{ label: copy.nav.deals, href: carplaceHref('/#offers'), icon: null },
|
||||
{ label: copy.nav.how, href: carplaceHref('/#how-it-works'), icon: null },
|
||||
{ label: copy.nav.help, href: carplaceHref('/#help'), icon: null },
|
||||
]
|
||||
|
||||
const currentLocale = localeOptions.find((item) => item.value === language) ?? localeOptions[0]
|
||||
|
||||
return (
|
||||
<header className="carplace-header">
|
||||
<div className="shell flex min-h-[76px] items-center gap-4">
|
||||
<Link href={homepageHref(language)} className="flex min-w-0 items-center gap-3 text-blue-950 no-underline dark:text-white" aria-label={copy.accessibility.home}>
|
||||
<Image src="/rentaldrivego.png" alt="" width={38} height={38} className="h-[38px] w-[38px] rounded-xl object-cover" priority unoptimized />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-base font-black leading-tight tracking-[-0.02em]">RentalDriveGo</span>
|
||||
<span className="block truncate text-[11px] font-bold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-400">{copy.brand}</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="mx-auto hidden items-center gap-1 lg:flex" aria-label="Carplace">
|
||||
{nav.map(({ label, href }) => (
|
||||
<Link key={href} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-stone-700 no-underline transition hover:bg-blue-50 hover:text-blue-900 dark:text-slate-200 dark:hover:bg-blue-900/50 dark:hover:text-white">
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ms-auto hidden items-center gap-2 md:flex">
|
||||
<div ref={languageRef} className="relative">
|
||||
<button type="button" className="carplace-control" onClick={() => setLanguageOpen((value) => !value)} aria-expanded={languageOpen} aria-haspopup="menu">
|
||||
<span aria-hidden="true">{currentLocale.flag}</span>
|
||||
<span>{language.toUpperCase()}</span>
|
||||
<ChevronDown className={`h-4 w-4 transition ${languageOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{languageOpen ? (
|
||||
<div className="carplace-menu end-0" role="menu">
|
||||
{localeOptions.map((option) => (
|
||||
<button key={option.value} type="button" role="menuitem" className="carplace-menu-item" onClick={() => { setLanguage(option.value); setLanguageOpen(false) }}>
|
||||
<span aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button type="button" className="carplace-icon-button" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} aria-label={theme === 'dark' ? copy.footer.light : copy.footer.dark}>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<a href={dashboardUrl} className="carplace-company-link">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{copy.actions.companySpace}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="button" className="carplace-icon-button ms-auto md:hidden" onClick={() => setMobileOpen((value) => !value)} aria-expanded={mobileOpen} aria-label={copy.accessibility.menu}>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileOpen ? (
|
||||
<div className="border-t border-stone-200 bg-white px-4 pb-5 pt-3 shadow-xl dark:border-blue-900 dark:bg-blue-950 md:hidden">
|
||||
<nav className="grid gap-1" aria-label={copy.accessibility.mobileNav}>
|
||||
{nav.map(({ label, href, icon: Icon }) => (
|
||||
<Link key={href} href={href} className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-stone-700 no-underline hover:bg-blue-50 dark:text-slate-200 dark:hover:bg-blue-900/50">
|
||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 border-t border-stone-200 pt-4 dark:border-blue-900">
|
||||
{localeOptions.map((option) => (
|
||||
<button key={option.value} type="button" onClick={() => setLanguage(option.value)} className={`carplace-control justify-center ${language === option.value ? 'border-orange-500 text-orange-700 dark:text-orange-300' : ''}`}>
|
||||
{option.flag} {option.value.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-[auto_1fr] gap-2">
|
||||
<button type="button" className="carplace-icon-button" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} aria-label={theme === 'dark' ? copy.footer.light : copy.footer.dark}>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
<a href={dashboardUrl} className="carplace-company-link justify-center"><Building2 className="h-4 w-4" />{copy.actions.companySpace}</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { CalendarDays, Clock3, MapPin, Search } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { CarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
|
||||
export type CarplaceSearchValues = {
|
||||
pickupLocation?: string
|
||||
dropoffLocation?: string
|
||||
dropoffMode?: 'same' | 'different'
|
||||
pickupDate?: string
|
||||
pickupTime?: string
|
||||
returnDate?: string
|
||||
returnTime?: string
|
||||
driverAge?: string
|
||||
promoCode?: string
|
||||
}
|
||||
|
||||
export default function CarplaceSearchForm({ cities, copy, initial = {}, compact = false }: { cities: string[]; copy: CarplaceMessages; initial?: CarplaceSearchValues; compact?: boolean }) {
|
||||
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>(initial.dropoffMode === 'different' ? 'different' : 'same')
|
||||
const [dateError, setDateError] = useState<string | null>(null)
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
function validateDates(event: FormEvent<HTMLFormElement>) {
|
||||
const data = new FormData(event.currentTarget)
|
||||
const pickup = String(data.get('pickupDate') ?? '')
|
||||
const pickupTime = String(data.get('pickupTime') ?? '00:00')
|
||||
const returned = String(data.get('returnDate') ?? '')
|
||||
const returnTime = String(data.get('returnTime') ?? '00:00')
|
||||
if (!pickup || !returned || new Date(`${returned}T${returnTime}:00`) <= new Date(`${pickup}T${pickupTime}:00`)) {
|
||||
event.preventDefault()
|
||||
setDateError(copy.search.invalidDates)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={carplaceHref('/search')} method="get" onSubmit={validateDates} className={compact ? 'carplace-search carplace-search-compact' : 'carplace-search'}>
|
||||
{!compact ? <h2 className="text-xl font-black text-blue-950 dark:text-white">{copy.search.title}</h2> : null}
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SearchField icon={MapPin} label={copy.search.pickup}>
|
||||
<input list="carplace-cities" name="pickupLocation" defaultValue={initial.pickupLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
|
||||
</SearchField>
|
||||
<SearchField icon={MapPin} label={copy.search.returnLocation}>
|
||||
{dropoffMode === 'same' ? (
|
||||
<div className="carplace-input flex items-center text-stone-500 dark:text-slate-400">{copy.search.sameReturn}</div>
|
||||
) : (
|
||||
<input list="carplace-cities" name="dropoffLocation" defaultValue={initial.dropoffLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
|
||||
)}
|
||||
</SearchField>
|
||||
<SearchField icon={CalendarDays} label={copy.search.pickupDate}>
|
||||
<div className="grid grid-cols-[1fr_7rem] gap-2">
|
||||
<input type="date" name="pickupDate" min={today} defaultValue={initial.pickupDate ?? ''} required className="carplace-input min-w-0" />
|
||||
<input type="time" name="pickupTime" defaultValue={initial.pickupTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.pickupTime} />
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField icon={CalendarDays} label={copy.search.returnDate}>
|
||||
<div className="grid grid-cols-[1fr_7rem] gap-2">
|
||||
<input type="date" name="returnDate" min={today} defaultValue={initial.returnDate ?? ''} required className="carplace-input min-w-0" />
|
||||
<input type="time" name="returnTime" defaultValue={initial.returnTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.returnTime} />
|
||||
</div>
|
||||
</SearchField>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={copy.search.returnLocation}>
|
||||
<label className={`carplace-choice ${dropoffMode === 'same' ? 'carplace-choice-active' : ''}`}>
|
||||
<input type="radio" name="dropoffMode" value="same" checked={dropoffMode === 'same'} onChange={() => setDropoffMode('same')} className="sr-only" />
|
||||
{copy.search.sameReturn}
|
||||
</label>
|
||||
<label className={`carplace-choice ${dropoffMode === 'different' ? 'carplace-choice-active' : ''}`}>
|
||||
<input type="radio" name="dropoffMode" value="different" checked={dropoffMode === 'different'} onChange={() => setDropoffMode('different')} className="sr-only" />
|
||||
{copy.search.differentReturn}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 gap-3 sm:grid-cols-2 lg:max-w-xl">
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
|
||||
{copy.search.age}
|
||||
<select name="driverAge" defaultValue={initial.driverAge ?? '25'} className="carplace-input">
|
||||
<option value="18">18+</option><option value="21">21+</option><option value="23">23+</option><option value="25">25+</option><option value="30">30+</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
|
||||
{copy.search.promo} <span className="font-normal text-stone-400">({copy.search.optional})</span>
|
||||
<input name="promoCode" defaultValue={initial.promoCode ?? ''} className="carplace-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="carplace-primary-button lg:ms-auto">
|
||||
<Search className="h-4 w-4" />
|
||||
{copy.actions.search}
|
||||
</button>
|
||||
</div>
|
||||
{dateError ? <p role="alert" className="mt-3 text-sm font-semibold text-red-700 dark:text-red-300">{dateError}</p> : null}
|
||||
<datalist id="carplace-cities">{cities.map((city) => <option key={city} value={city} />)}</datalist>
|
||||
<span className="sr-only"><Clock3 />{copy.search.pickupTime}</span>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchField({ icon: Icon, label, children }: { icon: typeof MapPin; label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="grid min-w-0 gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
|
||||
<span className="flex items-center gap-2"><Icon className="h-4 w-4 text-orange-600 dark:text-orange-400" />{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client'
|
||||
|
||||
import { AlertCircle, CalendarDays, Check, ChevronLeft, ChevronRight, Loader2, Mail, MapPin, Phone, UserRound } from 'lucide-react'
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import type { CarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CarplaceLanguage, QuoteResponse, ReservationResult } from '@/lib/carplace/types'
|
||||
import { CarplaceApiError, carplacePost } from '@/lib/api'
|
||||
|
||||
export type BookingInitialValues = {
|
||||
pickupLocation?: string
|
||||
dropoffLocation?: string
|
||||
pickupDate?: string
|
||||
pickupTime?: string
|
||||
returnDate?: string
|
||||
returnTime?: string
|
||||
promoCode?: string
|
||||
driverAge?: string
|
||||
}
|
||||
|
||||
type FormState = Required<Omit<BookingInitialValues, 'promoCode'>> & {
|
||||
promoCode: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
notes: string
|
||||
consent: boolean
|
||||
}
|
||||
|
||||
export default function ProgressiveBookingFlow({
|
||||
vehicleId,
|
||||
companySlug,
|
||||
vehicleName,
|
||||
companyName,
|
||||
dailyRate,
|
||||
pickupLocations,
|
||||
allowDifferentDropoff,
|
||||
dropoffLocations,
|
||||
language,
|
||||
copy,
|
||||
initial,
|
||||
}: {
|
||||
vehicleId: string
|
||||
companySlug: string
|
||||
vehicleName: string
|
||||
companyName: string
|
||||
dailyRate: number
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
language: CarplaceLanguage
|
||||
copy: CarplaceMessages
|
||||
initial?: BookingInitialValues
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const idempotencyKey = useRef<string>(createIdempotencyKey())
|
||||
const [step, setStep] = useState(1)
|
||||
const [quote, setQuote] = useState<QuoteResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<FormState>({
|
||||
pickupLocation: initial?.pickupLocation || pickupLocations[0] || '',
|
||||
dropoffLocation: initial?.dropoffLocation || initial?.pickupLocation || pickupLocations[0] || '',
|
||||
pickupDate: initial?.pickupDate || '',
|
||||
pickupTime: initial?.pickupTime || '10:00',
|
||||
returnDate: initial?.returnDate || '',
|
||||
returnTime: initial?.returnTime || '10:00',
|
||||
promoCode: initial?.promoCode || '',
|
||||
driverAge: initial?.driverAge || '25',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: '',
|
||||
consent: false,
|
||||
})
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const startIso = useMemo(() => toIso(form.pickupDate, form.pickupTime), [form.pickupDate, form.pickupTime])
|
||||
const endIso = useMemo(() => toIso(form.returnDate, form.returnTime), [form.returnDate, form.returnTime])
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function track(eventName: string, metadata?: Record<string, string | number | boolean | null>) {
|
||||
try {
|
||||
await carplacePost('/carplace/events', {
|
||||
eventName,
|
||||
companySlug,
|
||||
vehicleId,
|
||||
path: window.location.pathname,
|
||||
metadata,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function validateTrip() {
|
||||
if (!form.pickupLocation || !form.pickupDate || !form.returnDate || !startIso || !endIso || new Date(endIso) <= new Date(startIso)) {
|
||||
setError(copy.search.invalidDates)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const nextQuote = await carplacePost<QuoteResponse>('/carplace/quotes', {
|
||||
vehicleId,
|
||||
startDate: startIso,
|
||||
endDate: endIso,
|
||||
pickupLocation: form.pickupLocation,
|
||||
returnLocation: form.dropoffLocation || form.pickupLocation,
|
||||
promoCode: form.promoCode || undefined,
|
||||
})
|
||||
setQuote(nextQuote)
|
||||
if (!nextQuote.available) {
|
||||
setError(copy.booking.unavailable)
|
||||
return
|
||||
}
|
||||
setStep(2)
|
||||
void track('trip_dates_selected', { rentalDays: nextQuote.rentalDays })
|
||||
} catch (caught) {
|
||||
const apiError = caught as CarplaceApiError
|
||||
if (apiError.code === 'unavailable') setError(copy.booking.unavailable)
|
||||
else if (apiError.code === 'invalid_dates') setError(copy.search.invalidDates)
|
||||
else setError(copy.booking.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function validateContact() {
|
||||
if (!form.firstName.trim() || !form.lastName.trim() || !form.email.trim() || !form.phone.trim()) {
|
||||
setError(copy.booking.error)
|
||||
return
|
||||
}
|
||||
setStep(3)
|
||||
void track('contact_details_started')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.consent || !startIso || !endIso) {
|
||||
setError(copy.booking.error)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Recheck price and availability immediately before creating the request.
|
||||
const refreshedQuote = await carplacePost<QuoteResponse>('/carplace/quotes', {
|
||||
vehicleId,
|
||||
startDate: startIso,
|
||||
endDate: endIso,
|
||||
pickupLocation: form.pickupLocation,
|
||||
returnLocation: form.dropoffLocation || form.pickupLocation,
|
||||
promoCode: form.promoCode || undefined,
|
||||
})
|
||||
if (!refreshedQuote.available) {
|
||||
setStep(1)
|
||||
setQuote(refreshedQuote)
|
||||
setError(copy.booking.unavailable)
|
||||
return
|
||||
}
|
||||
setQuote(refreshedQuote)
|
||||
|
||||
const result = await carplacePost<ReservationResult>('/carplace/reservations', {
|
||||
vehicleId,
|
||||
companySlug,
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim(),
|
||||
driverAge: Number(form.driverAge),
|
||||
startDate: startIso,
|
||||
endDate: endIso,
|
||||
pickupLocation: form.pickupLocation,
|
||||
returnLocation: form.dropoffLocation || form.pickupLocation,
|
||||
promoCode: form.promoCode || undefined,
|
||||
notes: form.notes.trim() || undefined,
|
||||
language,
|
||||
idempotencyKey: idempotencyKey.current,
|
||||
}, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey.current },
|
||||
})
|
||||
|
||||
const reference = result.bookingReference || result.reservationId
|
||||
try {
|
||||
sessionStorage.setItem(`carplace-request:${reference}`, JSON.stringify({ ...result, quote: refreshedQuote, vehicleName, companyName }))
|
||||
} catch {}
|
||||
void track('booking_request_success', { reference })
|
||||
router.push(carplaceHref(`/request/${encodeURIComponent(reference)}`))
|
||||
} catch (caught) {
|
||||
const apiError = caught as CarplaceApiError
|
||||
void track('booking_request_failed', { code: apiError.code ?? 'unknown' })
|
||||
if (apiError.code === 'unavailable') {
|
||||
setStep(1)
|
||||
setError(copy.booking.unavailable)
|
||||
} else if (apiError.code === 'invalid_dates') {
|
||||
setStep(1)
|
||||
setError(copy.search.invalidDates)
|
||||
} else {
|
||||
setError(copy.booking.error)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="carplace-booking" aria-labelledby="booking-title">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">{copy.vehicle.estimated}</p>
|
||||
<h2 id="booking-title" className="mt-1 text-xl font-black text-blue-950 dark:text-white">{copy.booking.title}</h2>
|
||||
</div>
|
||||
<p className="text-sm font-black text-blue-950 dark:text-white">{formatCurrency(dailyRate, 'MAD', language)} <span className="text-xs font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.perDay}</span></p>
|
||||
</div>
|
||||
|
||||
<ol className="mt-5 grid grid-cols-3 gap-2" aria-label={copy.booking.title}>
|
||||
{[copy.booking.stepTrip, copy.booking.stepContact, copy.booking.stepReview].map((label, index) => {
|
||||
const number = index + 1
|
||||
const active = number === step
|
||||
const done = number < step
|
||||
return <li key={label} className={`carplace-step ${active ? 'carplace-step-active' : ''} ${done ? 'carplace-step-done' : ''}`}><span>{done ? <Check className="h-3.5 w-3.5" /> : number}</span>{label}</li>
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{error ? <div className="mt-4 flex gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200"><AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />{error}</div> : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="mt-5 grid gap-4">
|
||||
<BookingField label={copy.search.pickup} icon={MapPin}>
|
||||
<select className="carplace-input" value={form.pickupLocation} onChange={(event) => { update('pickupLocation', event.target.value); if (!allowDifferentDropoff) update('dropoffLocation', event.target.value) }} required>
|
||||
{pickupLocations.length === 0 ? <option value="">{copy.search.locationPlaceholder}</option> : null}
|
||||
{pickupLocations.map((location) => <option key={location}>{location}</option>)}
|
||||
</select>
|
||||
</BookingField>
|
||||
<BookingField label={copy.search.returnLocation} icon={MapPin}>
|
||||
{allowDifferentDropoff ? (
|
||||
<select className="carplace-input" value={form.dropoffLocation} onChange={(event) => update('dropoffLocation', event.target.value)}>
|
||||
{[...new Set([form.pickupLocation, ...dropoffLocations])].filter(Boolean).map((location) => <option key={location}>{location}</option>)}
|
||||
</select>
|
||||
) : <div className="carplace-input flex items-center text-stone-600 dark:text-slate-300">{form.pickupLocation || copy.search.sameReturn}</div>}
|
||||
</BookingField>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<BookingField label={copy.search.pickupDate} icon={CalendarDays}>
|
||||
<div className="grid grid-cols-[1fr_6.5rem] gap-2"><input type="date" min={today} className="carplace-input min-w-0" value={form.pickupDate} onChange={(event) => update('pickupDate', event.target.value)} /><input type="time" className="carplace-input min-w-0" value={form.pickupTime} onChange={(event) => update('pickupTime', event.target.value)} /></div>
|
||||
</BookingField>
|
||||
<BookingField label={copy.search.returnDate} icon={CalendarDays}>
|
||||
<div className="grid grid-cols-[1fr_6.5rem] gap-2"><input type="date" min={form.pickupDate || today} className="carplace-input min-w-0" value={form.returnDate} onChange={(event) => update('returnDate', event.target.value)} /><input type="time" className="carplace-input min-w-0" value={form.returnTime} onChange={(event) => update('returnTime', event.target.value)} /></div>
|
||||
</BookingField>
|
||||
</div>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.search.age}<select className="carplace-input" value={form.driverAge} onChange={(event) => update('driverAge', event.target.value)}><option value="18">18+</option><option value="21">21+</option><option value="23">23+</option><option value="25">25+</option><option value="30">30+</option></select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.search.promo} <span className="font-normal text-stone-400">({copy.search.optional})</span><input className="carplace-input" value={form.promoCode} onChange={(event) => update('promoCode', event.target.value)} /></label>
|
||||
{quote ? <QuoteBox quote={quote} language={language} copy={copy} /> : null}
|
||||
<button type="button" className="carplace-primary-button justify-center" onClick={validateTrip} disabled={loading}>{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ChevronRight className="h-4 w-4 rtl:rotate-180" />}{copy.actions.continue}</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="mt-5 grid gap-4">
|
||||
<p className="text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.booking.contactIntro}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<BookingField label={copy.booking.firstName} icon={UserRound}><input className="carplace-input" value={form.firstName} onChange={(event) => update('firstName', event.target.value)} autoComplete="given-name" /></BookingField>
|
||||
<BookingField label={copy.booking.lastName} icon={UserRound}><input className="carplace-input" value={form.lastName} onChange={(event) => update('lastName', event.target.value)} autoComplete="family-name" /></BookingField>
|
||||
</div>
|
||||
<BookingField label={copy.booking.email} icon={Mail}><input type="email" className="carplace-input" value={form.email} onChange={(event) => update('email', event.target.value)} autoComplete="email" /></BookingField>
|
||||
<BookingField label={copy.booking.phone} icon={Phone}><input type="tel" className="carplace-input" value={form.phone} onChange={(event) => update('phone', event.target.value)} autoComplete="tel" /></BookingField>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.booking.notes} <span className="font-normal text-stone-400">({copy.search.optional})</span><textarea className="carplace-input min-h-24 resize-y" value={form.notes} onChange={(event) => update('notes', event.target.value)} placeholder={copy.booking.notesPlaceholder} maxLength={500} /></label>
|
||||
<div className="grid grid-cols-2 gap-2"><button type="button" className="carplace-secondary-button justify-center" onClick={() => setStep(1)}><ChevronLeft className="h-4 w-4 rtl:rotate-180" />{copy.actions.back}</button><button type="button" className="carplace-primary-button justify-center" onClick={validateContact}><ChevronRight className="h-4 w-4 rtl:rotate-180" />{copy.actions.continue}</button></div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="mt-5 grid gap-4">
|
||||
<h3 className="text-base font-black text-blue-950 dark:text-white">{copy.booking.reviewTitle}</h3>
|
||||
<dl className="grid gap-3 rounded-2xl bg-stone-50 p-4 text-sm dark:bg-blue-950/60">
|
||||
<ReviewRow label={copy.vehicle.company} value={companyName} />
|
||||
<ReviewRow label={copy.actions.viewVehicle} value={vehicleName} />
|
||||
<ReviewRow label={copy.search.pickup} value={`${form.pickupLocation} · ${formatLocalDate(startIso, language)}`} />
|
||||
<ReviewRow label={copy.search.returnLocation} value={`${form.dropoffLocation || form.pickupLocation} · ${formatLocalDate(endIso, language)}`} />
|
||||
<ReviewRow label={copy.booking.email} value={form.email} />
|
||||
<ReviewRow label={copy.booking.phone} value={form.phone} />
|
||||
<ReviewRow label={copy.search.age} value={`${form.driverAge}+`} />
|
||||
</dl>
|
||||
{quote ? <QuoteBox quote={quote} language={language} copy={copy} /> : null}
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm leading-6 text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">{copy.booking.pendingNotice}</p>
|
||||
<label className="flex items-start gap-3 text-sm leading-6 text-stone-700 dark:text-slate-200"><input type="checkbox" className="mt-1 h-4 w-4 accent-orange-600" checked={form.consent} onChange={(event) => update('consent', event.target.checked)} /><span>{copy.booking.consent}</span></label>
|
||||
<div className="grid grid-cols-2 gap-2"><button type="button" className="carplace-secondary-button justify-center" onClick={() => setStep(2)} disabled={loading}><ChevronLeft className="h-4 w-4 rtl:rotate-180" />{copy.actions.back}</button><button type="button" className="carplace-primary-button justify-center" onClick={submit} disabled={loading || !form.consent}>{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}{copy.actions.submit}</button></div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BookingField({ label, icon: Icon, children }: { label: string; icon: typeof MapPin; children: ReactNode }) {
|
||||
return <label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300"><span className="flex items-center gap-2"><Icon className="h-4 w-4 text-orange-600 dark:text-orange-400" />{label}</span>{children}</label>
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return <div className="grid gap-1 sm:grid-cols-[8rem_1fr]"><dt className="font-semibold text-stone-500 dark:text-slate-400">{label}</dt><dd className="font-semibold text-blue-950 dark:text-white">{value}</dd></div>
|
||||
}
|
||||
|
||||
function QuoteBox({ quote, language, copy }: { quote: QuoteResponse; language: CarplaceLanguage; copy: CarplaceMessages }) {
|
||||
return <div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/60"><div className="flex items-center justify-between gap-3"><div><p className="text-xs font-bold uppercase tracking-[0.14em] text-blue-700 dark:text-blue-300">{copy.vehicle.estimated}</p><p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{quote.rentalDays} × {formatCurrency(quote.dailyRate, 'MAD', language)}</p></div><p className="text-xl font-black text-blue-950 dark:text-white">{formatCurrency(quote.estimatedTotal, 'MAD', language)}</p></div><p className="mt-3 text-xs leading-5 text-stone-600 dark:text-slate-300">{copy.vehicle.estimateDisclaimer}</p></div>
|
||||
}
|
||||
|
||||
function toIso(date: string, time: string): string | null {
|
||||
if (!date || !time) return null
|
||||
const value = new Date(`${date}T${time}:00`)
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString()
|
||||
}
|
||||
|
||||
function formatLocalDate(value: string | null, language: CarplaceLanguage): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-MA' : 'en-MA', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
|
||||
const random = Math.floor(Math.random() * 16)
|
||||
const value = character === 'x' ? random : (random & 0x3) | 0x8
|
||||
return value.toString(16)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Clock3, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { ReservationResult } from '@/lib/carplace/types'
|
||||
|
||||
export default function RequestSuccessClient({ reference }: { reference: string }) {
|
||||
const { language } = useCarplacePreferences()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const [result, setResult] = useState<ReservationResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`carplace-request:${reference}`)
|
||||
if (raw) setResult(JSON.parse(raw) as ReservationResult)
|
||||
} catch {}
|
||||
}, [reference])
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="shell py-16 sm:py-24">
|
||||
<section className="mx-auto max-w-2xl rounded-[2rem] border border-emerald-200 bg-white p-8 text-center shadow-[0_30px_80px_rgba(15,23,42,0.10)] dark:border-emerald-900 dark:bg-blue-950 sm:p-12">
|
||||
<CheckCircle2 className="mx-auto h-14 w-14 text-emerald-600 dark:text-emerald-400" />
|
||||
<p className="mt-6 text-xs font-bold uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-300">Carplace</p>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-white">{copy.booking.successTitle}</h1>
|
||||
<p className="mt-4 leading-7 text-stone-600 dark:text-slate-300">{copy.booking.successBody}</p>
|
||||
<dl className="mt-8 grid gap-3 rounded-2xl bg-stone-50 p-5 text-start dark:bg-blue-900/30">
|
||||
<div className="flex items-center justify-between gap-4"><dt className="text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.booking.reference}</dt><dd className="font-black text-blue-950 dark:text-white">{result?.bookingReference || reference}</dd></div>
|
||||
<div className="flex items-center justify-between gap-4"><dt className="text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.booking.status}</dt><dd className="inline-flex items-center gap-2 rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-900 dark:bg-amber-950 dark:text-amber-100"><Clock3 className="h-3.5 w-3.5" />{copy.booking.pending}</dd></div>
|
||||
</dl>
|
||||
<Link href={carplaceHref('/search')} className="carplace-primary-button mt-8 justify-center"><Search className="h-4 w-4" />{copy.actions.search}</Link>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ArrowRight, Fuel, Gauge, MapPin, Star, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import type { CarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceVehicleHref } from '@/lib/carplace/routes'
|
||||
import type { CarplaceLanguage, VehicleSummary } from '@/lib/carplace/types'
|
||||
|
||||
export default function VehicleCard({ vehicle, copy, language, query }: { vehicle: VehicleSummary; copy: CarplaceMessages; language: CarplaceLanguage; query?: URLSearchParams | string }) {
|
||||
const brand = vehicle.company.brand
|
||||
const companySlug = brand?.subdomain || vehicle.company.slug
|
||||
const available = vehicle.availability !== false
|
||||
const location = vehicle.pickupLocations?.[0] || brand?.publicCity
|
||||
|
||||
return (
|
||||
<article className="carplace-vehicle-card">
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-gradient-to-br from-blue-100 to-stone-100 dark:from-blue-900 dark:to-blue-950">
|
||||
{vehicle.photos?.[0] ? (
|
||||
// Kept unoptimized because company uploads can come from configured storage hosts.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.year} ${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover transition duration-500 group-hover:scale-105" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.noPhoto}</div>
|
||||
)}
|
||||
<span className={`absolute start-3 top-3 rounded-full px-3 py-1 text-xs font-bold shadow-sm ${available ? 'bg-emerald-600 text-white' : 'bg-stone-900/85 text-white'}`}>
|
||||
{available ? copy.vehicle.available : copy.vehicle.unavailable}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-bold uppercase tracking-[0.16em] text-orange-600 dark:text-orange-400">{copy.categories[vehicle.category as keyof typeof copy.categories] ?? vehicle.category}</p>
|
||||
<h3 className="mt-2 truncate text-xl font-black tracking-[-0.02em] text-blue-950 dark:text-white">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{vehicle.year}</p>
|
||||
</div>
|
||||
{brand?.carplaceRating ? (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950/50 dark:text-amber-200"><Star className="h-3.5 w-3.5 fill-current" />{brand.carplaceRating.toFixed(1)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-semibold text-stone-600 dark:text-slate-300">
|
||||
{vehicle.transmission ? <span className="carplace-spec"><Gauge className="h-3.5 w-3.5" />{vehicle.transmission}</span> : null}
|
||||
{vehicle.seats ? <span className="carplace-spec"><Users className="h-3.5 w-3.5" />{vehicle.seats}</span> : null}
|
||||
{vehicle.fuelType ? <span className="carplace-spec"><Fuel className="h-3.5 w-3.5" />{vehicle.fuelType}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-stone-200 pt-4 dark:border-blue-900">
|
||||
<p className="truncate text-sm font-semibold text-stone-700 dark:text-slate-200">{brand?.displayName ?? copy.vehicle.company}</p>
|
||||
{location ? <p className="mt-1 flex items-center gap-1.5 text-xs text-stone-500 dark:text-slate-400"><MapPin className="h-3.5 w-3.5" />{location}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-4 pt-5">
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 dark:text-slate-400">{copy.vehicle.from}</p>
|
||||
<p className="text-xl font-black text-blue-950 dark:text-white">{formatCurrency(vehicle.dailyRate, 'MAD', language)} <span className="text-xs font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.perDay}</span></p>
|
||||
</div>
|
||||
<Link href={carplaceVehicleHref(companySlug, vehicle.id, query)} className="carplace-card-action" aria-label={`${copy.actions.viewVehicle}: ${vehicle.make} ${vehicle.model}`}>
|
||||
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CarplaceApiError, carplaceFetch, carplaceFetchOrDefault, carplacePost } from './api'
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_API_URL
|
||||
delete process.env.API_INTERNAL_URL
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
})
|
||||
|
||||
describe('carplace API helpers', () => {
|
||||
it('unwraps successful GET responses from the data envelope', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
await expect(carplaceFetch('/carplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/carplace\/vehicles$/), {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws rich carplace API errors', async () => {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ message: 'Vehicle unavailable', error: 'VEHICLE_UNAVAILABLE', nextAvailableAt: '2026-07-01T10:00:00.000Z' }),
|
||||
})),
|
||||
})
|
||||
|
||||
await expect(carplaceFetch('/carplace/book')).rejects.toMatchObject({
|
||||
name: 'CarplaceApiError',
|
||||
message: 'Vehicle unavailable',
|
||||
status: 409,
|
||||
code: 'VEHICLE_UNAVAILABLE',
|
||||
nextAvailableAt: '2026-07-01T10:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns fallbacks when GET requests fail', async () => {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })),
|
||||
})
|
||||
|
||||
await expect(carplaceFetchOrDefault('/carplace/homepage', { hero: null })).resolves.toEqual({ hero: null })
|
||||
})
|
||||
|
||||
it('posts JSON payloads and unwraps successful responses', async () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com/api/v1'
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
await expect(carplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses a generic error message when the response body is not JSON', async () => {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })),
|
||||
})
|
||||
|
||||
await expect(carplaceFetch('/site/homepage')).rejects.toMatchObject({
|
||||
name: 'CarplaceApiError',
|
||||
message: 'Request failed',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
function normalizeApiBase(value: string): string {
|
||||
const base = value.replace(/\/+$/, '')
|
||||
return /\/api\/v1$/.test(base) ? base : `${base}/api/v1`
|
||||
}
|
||||
|
||||
function resolveApiBase(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return normalizeApiBase(process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000/api/v1')
|
||||
}
|
||||
|
||||
// Same-origin is the production default. Direct cross-subdomain API calls are
|
||||
// opt-in because host-only cookies otherwise become invisible to sibling apps.
|
||||
if (process.env.NEXT_PUBLIC_CARPLACE_DIRECT_API === 'true' && process.env.NEXT_PUBLIC_API_URL) {
|
||||
return normalizeApiBase(process.env.NEXT_PUBLIC_API_URL)
|
||||
}
|
||||
|
||||
return '/carplace/api/v1'
|
||||
}
|
||||
|
||||
export const API_BASE = resolveApiBase()
|
||||
|
||||
export class CarplaceApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
nextAvailableAt?: string | null
|
||||
|
||||
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
|
||||
super(message)
|
||||
this.name = 'CarplaceApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.nextAvailableAt = nextAvailableAt
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponse<T>(res: Response): Promise<T> {
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new CarplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
export async function carplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
...init,
|
||||
})
|
||||
return parseResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function carplaceFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await carplaceFetch<T>(path)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function carplacePost<T>(path: string, body: unknown, init?: Omit<RequestInit, 'method' | 'body'>): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return parseResponse<T>(res)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveBrowserAppUrl, resolveServerAppUrl } from './appUrls'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('carplace app URL resolution', () => {
|
||||
it('leaves server-side browser fallback unchanged when window is unavailable', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('preserves localhost fallbacks but removes trailing slash', () => {
|
||||
installWindow('localhost')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000/')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('rewrites production browser fallbacks to the active host and protocol', () => {
|
||||
installWindow('www.rentaldrivego.ma', 'https:')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('https://www.rentaldrivego.ma')
|
||||
})
|
||||
|
||||
it('keeps path prefixes while rewriting the browser origin', () => {
|
||||
installWindow('rental.example.com', 'https:')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000/carplace')).toBe('https://rental.example.com/carplace')
|
||||
})
|
||||
|
||||
it('resolves server URLs from forwarded host/proto and handles invalid fallbacks safely', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3000', 'market.example.com', 'https')).toBe('https://market.example.com:3000')
|
||||
expect(resolveServerAppUrl('http://localhost:3000', null)).toBe('http://localhost:3000')
|
||||
expect(resolveServerAppUrl('/relative', 'market.example.com')).toBe('/relative')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
export function resolveBrowserAppUrl(fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
const h = window.location.hostname
|
||||
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = window.location.protocol
|
||||
target.hostname = h
|
||||
target.port = ''
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
|
||||
if (!host) return fallback
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = `${proto || target.protocol.replace(':', '')}:`
|
||||
target.host = host
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { CarplaceLanguage } from '@/lib/i18n'
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
brand: 'Carplace',
|
||||
tagline: 'Rental cars from trusted local companies',
|
||||
accessibility: { home: 'RentalDriveGo home', menu: 'Menu', mobileNav: 'Carplace mobile navigation', pagination: 'Search result pages' },
|
||||
nav: { find: 'Find a car', companies: 'Rental companies', deals: 'Deals', how: 'How it works', help: 'Help' },
|
||||
actions: { companySpace: 'Company space', search: 'Search cars', editSearch: 'Edit search', viewVehicle: 'View vehicle', viewFleet: 'View fleet', request: 'Request this vehicle', back: 'Back', continue: 'Continue', submit: 'Send reservation request', retry: 'Try again' },
|
||||
hero: { eyebrow: 'RentalDriveGo Carplace', title: 'Find the right rental car with clear information.', body: 'Compare vehicles from local rental companies, check availability for your dates, and send a clear reservation request. The company confirms directly with you.' },
|
||||
search: { title: 'Plan your rental', pickup: 'Pick-up location', returnLocation: 'Return location', sameReturn: 'Return to the same location', differentReturn: 'Return somewhere else', pickupDate: 'Pick-up date', pickupTime: 'Pick-up time', returnDate: 'Return date', returnTime: 'Return time', age: 'Driver age', promo: 'Promo code', optional: 'Optional', locationPlaceholder: 'Choose a city', invalidDates: 'Return must be after pick-up.' },
|
||||
trust: [
|
||||
['Local inventory', 'Browse vehicles published by active rental companies.'],
|
||||
['Date-specific checks', 'Availability is checked for the dates you select.'],
|
||||
['Direct confirmation', 'Your request remains pending until the company confirms it.'],
|
||||
['Clear estimates', 'Displayed totals are estimates and require final company confirmation.'],
|
||||
],
|
||||
sections: { cities: 'Browse by city', categories: 'Choose a vehicle type', offers: 'Current deals', vehicles: 'Recommended vehicles', companies: 'Rental companies', how: 'How Carplace works', guidance: 'Before you request', faq: 'Common questions', results: 'Available vehicles', filters: 'Filter results' },
|
||||
categories: { ECONOMY: 'Economy', COMPACT: 'Compact', MIDSIZE: 'Midsize', FULLSIZE: 'Full-size', SUV: 'SUV', LUXURY: 'Luxury', VAN: 'Van', TRUCK: 'Truck' },
|
||||
how: [
|
||||
['Search', 'Choose a location and rental dates.'],
|
||||
['Compare', 'Review vehicles, companies, prices, and important conditions.'],
|
||||
['Request', 'Send your contact details and trip information securely.'],
|
||||
['Confirm', 'The rental company reviews the request and contacts you.'],
|
||||
],
|
||||
guidance: [
|
||||
['Bring valid documents', 'The company may request identification and a valid driving licence before pickup.'],
|
||||
['Expect a deposit', 'Deposit, insurance, mileage, and delivery conditions are set by the rental company.'],
|
||||
['Review final terms', 'The company must confirm availability and the final amount before the rental is accepted.'],
|
||||
],
|
||||
faq: [
|
||||
['Is my reservation confirmed immediately?', 'No. Carplace submits a request. The rental company confirms availability and final terms directly with you.'],
|
||||
['Is the displayed amount the final price?', 'It is an estimated rental subtotal. Taxes, deposit, insurance, delivery, and company charges may apply.'],
|
||||
['Do I need a renter account?', 'No. Carplace currently supports a guest reservation-request flow.'],
|
||||
],
|
||||
vehicle: { from: 'From', perDay: '/ day', estimated: 'Estimated rental subtotal', estimateDisclaimer: 'Final price, deposit, insurance, taxes, mileage, and delivery terms require company confirmation.', available: 'Available for selected dates', unavailable: 'Unavailable for selected dates', availableFrom: 'Next available', company: 'Rental company', rating: 'Rating', new: 'New listing', seats: 'Seats', transmission: 'Transmission', fuel: 'Fuel', category: 'Category', year: 'Year', features: 'Included features', pickupLocations: 'Pick-up locations', noPhoto: 'Vehicle photo unavailable', directContact: 'Direct company contact', phone: 'Call company', whatsapp: 'WhatsApp company', similar: 'Similar vehicles' },
|
||||
results: { title: 'Cars matching your trip', count: 'vehicles found', none: 'No vehicles match this search yet.', noneBody: 'Try another city, broader category, or different dates.', error: 'Search results are temporarily unavailable.', sort: 'Sort', sortPriceAsc: 'Lowest daily price', sortPriceDesc: 'Highest daily price', allCategories: 'All categories', maxPrice: 'Maximum daily rate', transmission: 'Transmission', make: 'Make', model: 'Model', apply: 'Apply filters', clear: 'Clear filters', previous: 'Previous', next: 'Next' },
|
||||
company: { partner: 'Carplace rental company', fleet: 'Available fleet', offers: 'Current offers', contact: 'Contact company', policies: 'Rental information', noOffers: 'No active public offers.', vehicles: 'published vehicles', reviews: 'reviews' },
|
||||
booking: { title: 'Reservation request', stepTrip: 'Trip', stepContact: 'Contact', stepReview: 'Review', contactIntro: 'The company uses these details to confirm the request.', firstName: 'First name', lastName: 'Last name', email: 'Email', phone: 'Phone', notes: 'Notes for the company', notesPlaceholder: 'Flight number, delivery request, or another useful detail', reviewTitle: 'Review your request', pendingNotice: 'Submitting this form does not confirm the booking. The company will review availability and contact you.', consent: 'I agree that my contact and trip details may be shared with the selected rental company for this request.', successTitle: 'Your request was sent', successBody: 'The rental company will review it and contact you. Do not consider the vehicle confirmed until the company accepts the request.', reference: 'Request reference', status: 'Status', pending: 'Pending company confirmation', error: 'The request could not be submitted.', unavailable: 'The vehicle is no longer available for these dates.', privacy: 'Privacy policy', terms: 'Terms' },
|
||||
footer: { description: 'A renter-first car rental platform by RentalDriveGo. Search locally, request clearly, and receive confirmation directly from the rental company.', explore: 'Explore', support: 'Support', business: 'For rental companies', rights: 'All rights reserved.', language: 'Language', theme: 'Theme', light: 'Light', dark: 'Dark' },
|
||||
status: { unavailable: 'Carplace unavailable', unavailableBody: 'Carplace data is temporarily unavailable. Please try again shortly.' },
|
||||
},
|
||||
fr: {
|
||||
brand: 'Carplace',
|
||||
tagline: 'Voitures de location auprès de sociétés locales fiables',
|
||||
accessibility: { home: 'Accueil RentalDriveGo', menu: 'Menu', mobileNav: 'Navigation mobile Carplace', pagination: 'Pages de résultats' },
|
||||
nav: { find: 'Trouver une voiture', companies: 'Sociétés de location', deals: 'Offres', how: 'Fonctionnement', help: 'Aide' },
|
||||
actions: { companySpace: 'Espace entreprise', search: 'Rechercher', editSearch: 'Modifier la recherche', viewVehicle: 'Voir le véhicule', viewFleet: 'Voir la flotte', request: 'Demander ce véhicule', back: 'Retour', continue: 'Continuer', submit: 'Envoyer la demande', retry: 'Réessayer' },
|
||||
hero: { eyebrow: 'RentalDriveGo Carplace', title: 'Trouvez la bonne voiture de location sans ambiguïté inutile.', body: 'Comparez les véhicules de sociétés locales, vérifiez la disponibilité pour vos dates et envoyez une demande claire. La société vous confirme directement.' },
|
||||
search: { title: 'Planifiez votre location', pickup: 'Lieu de prise en charge', returnLocation: 'Lieu de retour', sameReturn: 'Retour au même endroit', differentReturn: 'Retour à un autre endroit', pickupDate: 'Date de départ', pickupTime: 'Heure de départ', returnDate: 'Date de retour', returnTime: 'Heure de retour', age: 'Âge du conducteur', promo: 'Code promotionnel', optional: 'Facultatif', locationPlaceholder: 'Choisir une ville', invalidDates: 'Le retour doit être postérieur au départ.' },
|
||||
trust: [
|
||||
['Inventaire local', 'Parcourez les véhicules publiés par des sociétés de location actives.'],
|
||||
['Vérification par dates', 'La disponibilité est vérifiée pour les dates sélectionnées.'],
|
||||
['Confirmation directe', "La demande reste en attente jusqu'à la confirmation de la société."],
|
||||
['Estimations claires', 'Les montants affichés sont des estimations, pas des garanties inventées.'],
|
||||
],
|
||||
sections: { cities: 'Parcourir par ville', categories: 'Choisir un type de véhicule', offers: 'Offres actuelles', vehicles: 'Véhicules recommandés', companies: 'Sociétés de location', how: 'Comment fonctionne Carplace', guidance: 'Avant votre demande', faq: 'Questions fréquentes', results: 'Véhicules disponibles', filters: 'Filtrer les résultats' },
|
||||
categories: { ECONOMY: 'Économie', COMPACT: 'Compacte', MIDSIZE: 'Intermédiaire', FULLSIZE: 'Grande berline', SUV: 'SUV', LUXURY: 'Luxe', VAN: 'Van', TRUCK: 'Camion' },
|
||||
how: [
|
||||
['Recherchez', 'Choisissez un lieu et les dates de location.'],
|
||||
['Comparez', 'Examinez les véhicules, sociétés, prix et conditions importantes.'],
|
||||
['Demandez', 'Envoyez vos coordonnées et les informations du trajet.'],
|
||||
['Confirmez', 'La société étudie la demande et vous contacte.'],
|
||||
],
|
||||
guidance: [
|
||||
['Préparez vos documents', "La société peut demander une pièce d'identité et un permis valide avant la prise en charge."],
|
||||
['Prévoyez une caution', 'La caution, l’assurance, le kilométrage et la livraison sont fixés par la société.'],
|
||||
['Vérifiez les conditions finales', 'La société doit confirmer la disponibilité et le montant final avant acceptation.'],
|
||||
],
|
||||
faq: [
|
||||
['Ma réservation est-elle confirmée immédiatement ?', 'Non. Carplace envoie une demande. La société confirme directement la disponibilité et les conditions finales.'],
|
||||
['Le montant affiché est-il définitif ?', 'Il s’agit d’un sous-total estimé. Des taxes, une caution, une assurance, la livraison ou d’autres frais peuvent s’appliquer.'],
|
||||
["Ai-je besoin d'un compte client ?", 'Non. Carplace propose actuellement un parcours de demande en tant qu’invité.'],
|
||||
],
|
||||
vehicle: { from: 'À partir de', perDay: '/ jour', estimated: 'Sous-total estimé', estimateDisclaimer: 'Le prix final, la caution, l’assurance, les taxes, le kilométrage et la livraison nécessitent la confirmation de la société.', available: 'Disponible pour les dates sélectionnées', unavailable: 'Indisponible pour les dates sélectionnées', availableFrom: 'Prochaine disponibilité', company: 'Société de location', rating: 'Note', new: 'Nouvelle annonce', seats: 'Places', transmission: 'Transmission', fuel: 'Carburant', category: 'Catégorie', year: 'Année', features: 'Équipements inclus', pickupLocations: 'Lieux de prise en charge', noPhoto: 'Photo indisponible', directContact: 'Contact direct avec la société', phone: 'Appeler la société', whatsapp: 'WhatsApp de la société', similar: 'Véhicules similaires' },
|
||||
results: { title: 'Voitures correspondant à votre trajet', count: 'véhicules trouvés', none: 'Aucun véhicule ne correspond encore à cette recherche.', noneBody: 'Essayez une autre ville, une catégorie plus large ou d’autres dates.', error: 'Les résultats sont temporairement indisponibles.', sort: 'Trier', sortPriceAsc: 'Prix journalier croissant', sortPriceDesc: 'Prix journalier décroissant', allCategories: 'Toutes les catégories', maxPrice: 'Tarif journalier maximum', transmission: 'Transmission', make: 'Marque', model: 'Modèle', apply: 'Appliquer les filtres', clear: 'Effacer les filtres', previous: 'Précédent', next: 'Suivant' },
|
||||
company: { partner: 'Société de location Carplace', fleet: 'Flotte disponible', offers: 'Offres actuelles', contact: 'Contacter la société', policies: 'Informations de location', noOffers: 'Aucune offre publique active.', vehicles: 'véhicules publiés', reviews: 'avis' },
|
||||
booking: { title: 'Demande de réservation', stepTrip: 'Trajet', stepContact: 'Contact', stepReview: 'Vérification', contactIntro: 'La société utilise ces coordonnées pour confirmer la demande.', firstName: 'Prénom', lastName: 'Nom', email: 'E-mail', phone: 'Téléphone', notes: 'Note pour la société', notesPlaceholder: 'Numéro de vol, livraison ou autre information utile', reviewTitle: 'Vérifiez votre demande', pendingNotice: 'Ce formulaire ne confirme pas la réservation. La société vérifiera la disponibilité et vous contactera.', consent: 'J’accepte que mes coordonnées et les détails du trajet soient transmis à la société sélectionnée pour cette demande.', successTitle: 'Votre demande a été envoyée', successBody: 'La société va l’examiner et vous contacter. Le véhicule n’est confirmé qu’après acceptation par la société.', reference: 'Référence de la demande', status: 'Statut', pending: 'En attente de confirmation', error: "La demande n'a pas pu être envoyée.", unavailable: "Le véhicule n'est plus disponible pour ces dates.", privacy: 'Politique de confidentialité', terms: 'Conditions' },
|
||||
footer: { description: 'Une place de marché pensée pour les locataires par RentalDriveGo. Recherchez localement, envoyez une demande claire et recevez la confirmation de la société.', explore: 'Explorer', support: 'Assistance', business: 'Pour les sociétés', rights: 'Tous droits réservés.', language: 'Langue', theme: 'Thème', light: 'Clair', dark: 'Sombre' },
|
||||
status: { unavailable: 'Carplace indisponible', unavailableBody: 'Les données de la place de marché sont temporairement indisponibles.' },
|
||||
},
|
||||
ar: {
|
||||
brand: 'Carplace',
|
||||
tagline: 'سيارات للإيجار من شركات محلية موثوقة',
|
||||
accessibility: { home: 'الصفحة الرئيسية لـ RentalDriveGo', menu: 'القائمة', mobileNav: 'التنقل عبر Carplace على الهاتف', pagination: 'صفحات نتائج البحث' },
|
||||
nav: { find: 'ابحث عن سيارة', companies: 'شركات التأجير', deals: 'العروض', how: 'كيف تعمل', help: 'المساعدة' },
|
||||
actions: { companySpace: 'مساحة الشركة', search: 'البحث عن سيارات', editSearch: 'تعديل البحث', viewVehicle: 'عرض السيارة', viewFleet: 'عرض الأسطول', request: 'اطلب هذه السيارة', back: 'رجوع', continue: 'متابعة', submit: 'إرسال طلب الحجز', retry: 'إعادة المحاولة' },
|
||||
hero: { eyebrow: 'RentalDriveGo Carplace', title: 'اعثر على سيارة الإيجار المناسبة دون غموض غير ضروري.', body: 'قارن سيارات شركات التأجير المحلية، وتحقق من التوفر حسب تواريخك، ثم أرسل طلباً واضحاً. تتولى الشركة التأكيد معك مباشرة.' },
|
||||
search: { title: 'خطط لرحلتك', pickup: 'موقع الاستلام', returnLocation: 'موقع الإرجاع', sameReturn: 'الإرجاع في نفس الموقع', differentReturn: 'الإرجاع في موقع آخر', pickupDate: 'تاريخ الاستلام', pickupTime: 'وقت الاستلام', returnDate: 'تاريخ الإرجاع', returnTime: 'وقت الإرجاع', age: 'عمر السائق', promo: 'الرمز الترويجي', optional: 'اختياري', locationPlaceholder: 'اختر مدينة', invalidDates: 'يجب أن يكون الإرجاع بعد الاستلام.' },
|
||||
trust: [
|
||||
['سيارات محلية', 'تصفح السيارات المنشورة من شركات تأجير نشطة.'],
|
||||
['تحقق حسب التاريخ', 'يتم التحقق من التوفر للتواريخ التي تختارها.'],
|
||||
['تأكيد مباشر', 'يبقى الطلب قيد الانتظار حتى تؤكده شركة التأجير.'],
|
||||
['تقديرات واضحة', 'المبالغ المعروضة تقديرية وليست ضمانات مختلقة.'],
|
||||
],
|
||||
sections: { cities: 'تصفح حسب المدينة', categories: 'اختر نوع السيارة', offers: 'العروض الحالية', vehicles: 'سيارات مقترحة', companies: 'شركات التأجير', how: 'كيف تعمل Carplace', guidance: 'قبل إرسال الطلب', faq: 'أسئلة شائعة', results: 'السيارات المتاحة', filters: 'تصفية النتائج' },
|
||||
categories: { ECONOMY: 'اقتصادية', COMPACT: 'مدمجة', MIDSIZE: 'متوسطة', FULLSIZE: 'كبيرة', SUV: 'دفع رباعي', LUXURY: 'فاخرة', VAN: 'فان', TRUCK: 'شاحنة' },
|
||||
how: [
|
||||
['ابحث', 'اختر الموقع وتواريخ الإيجار.'],
|
||||
['قارن', 'راجع السيارات والشركات والأسعار والشروط المهمة.'],
|
||||
['أرسل الطلب', 'أدخل معلومات الرحلة وبيانات التواصل بأمان.'],
|
||||
['احصل على التأكيد', 'تراجع الشركة الطلب وتتواصل معك مباشرة.'],
|
||||
],
|
||||
guidance: [
|
||||
['جهز الوثائق', 'قد تطلب الشركة وثيقة هوية ورخصة قيادة سارية قبل الاستلام.'],
|
||||
['توقع مبلغ ضمان', 'تحدد الشركة مبلغ الضمان والتأمين والكيلومترات والتوصيل.'],
|
||||
['راجع الشروط النهائية', 'يجب أن تؤكد الشركة التوفر والمبلغ النهائي قبل قبول الإيجار.'],
|
||||
],
|
||||
faq: [
|
||||
['هل يتم تأكيد الحجز فوراً؟', 'لا. ترسل Carplace طلباً، وتؤكد شركة التأجير التوفر والشروط النهائية معك مباشرة.'],
|
||||
['هل المبلغ المعروض هو السعر النهائي؟', 'إنه مجموع إيجار تقديري. قد تضاف الضرائب أو الضمان أو التأمين أو التوصيل أو رسوم الشركة.'],
|
||||
['هل أحتاج إلى حساب مستأجر؟', 'لا. تدعم Carplace حالياً إرسال طلب الحجز كزائر.'],
|
||||
],
|
||||
vehicle: { from: 'ابتداءً من', perDay: '/ اليوم', estimated: 'مجموع الإيجار التقديري', estimateDisclaimer: 'السعر النهائي والضمان والتأمين والضرائب والكيلومترات والتوصيل تحتاج إلى تأكيد الشركة.', available: 'متاحة للتواريخ المحددة', unavailable: 'غير متاحة للتواريخ المحددة', availableFrom: 'أقرب موعد متاح', company: 'شركة التأجير', rating: 'التقييم', new: 'إعلان جديد', seats: 'المقاعد', transmission: 'ناقل الحركة', fuel: 'الوقود', category: 'الفئة', year: 'السنة', features: 'المزايا المتضمنة', pickupLocations: 'مواقع الاستلام', noPhoto: 'صورة السيارة غير متاحة', directContact: 'تواصل مباشر مع الشركة', phone: 'اتصل بالشركة', whatsapp: 'واتساب الشركة', similar: 'سيارات مشابهة' },
|
||||
results: { title: 'سيارات تناسب رحلتك', count: 'سيارات متاحة', none: 'لا توجد سيارات مطابقة لهذا البحث حالياً.', noneBody: 'جرّب مدينة أخرى أو فئة أوسع أو تواريخ مختلفة.', error: 'نتائج البحث غير متاحة مؤقتاً.', sort: 'الترتيب', sortPriceAsc: 'الأقل سعراً يومياً', sortPriceDesc: 'الأعلى سعراً يومياً', allCategories: 'كل الفئات', maxPrice: 'أقصى سعر يومي', transmission: 'ناقل الحركة', make: 'الشركة المصنعة', model: 'الطراز', apply: 'تطبيق الفلاتر', clear: 'مسح الفلاتر', previous: 'السابق', next: 'التالي' },
|
||||
company: { partner: 'شركة تأجير على Carplace', fleet: 'الأسطول المتاح', offers: 'العروض الحالية', contact: 'تواصل مع الشركة', policies: 'معلومات الإيجار', noOffers: 'لا توجد عروض عامة نشطة.', vehicles: 'سيارات منشورة', reviews: 'تقييمات' },
|
||||
booking: { title: 'طلب حجز', stepTrip: 'الرحلة', stepContact: 'التواصل', stepReview: 'المراجعة', contactIntro: 'تستخدم الشركة هذه البيانات لتأكيد الطلب.', firstName: 'الاسم الأول', lastName: 'اسم العائلة', email: 'البريد الإلكتروني', phone: 'الهاتف', notes: 'ملاحظات للشركة', notesPlaceholder: 'رقم الرحلة أو طلب التوصيل أو معلومة مفيدة', reviewTitle: 'راجع طلبك', pendingNotice: 'إرسال النموذج لا يؤكد الحجز. ستراجع الشركة التوفر وتتواصل معك.', consent: 'أوافق على مشاركة بيانات التواصل وتفاصيل الرحلة مع شركة التأجير المحددة لمعالجة هذا الطلب.', successTitle: 'تم إرسال طلبك', successBody: 'ستراجع الشركة الطلب وتتواصل معك. لا تعتبر السيارة مؤكدة حتى تقبل الشركة الطلب.', reference: 'مرجع الطلب', status: 'الحالة', pending: 'بانتظار تأكيد الشركة', error: 'تعذر إرسال الطلب.', unavailable: 'لم تعد السيارة متاحة لهذه التواريخ.', privacy: 'سياسة الخصوصية', terms: 'الشروط' },
|
||||
footer: { description: 'سوق للمستأجرين من RentalDriveGo. ابحث محلياً، وأرسل طلباً واضحاً، واحصل على التأكيد مباشرة من شركة التأجير.', explore: 'استكشف', support: 'الدعم', business: 'لشركات التأجير', rights: 'جميع الحقوق محفوظة.', language: 'اللغة', theme: 'المظهر', light: 'فاتح', dark: 'داكن' },
|
||||
status: { unavailable: 'Carplace غير متاحة', unavailableBody: 'بيانات السوق غير متاحة مؤقتاً.' },
|
||||
},
|
||||
} as const
|
||||
|
||||
export type CarplaceMessages = (typeof messages)['en']
|
||||
|
||||
export function getCarplaceMessages(language: CarplaceLanguage): CarplaceMessages {
|
||||
return messages[language] as unknown as CarplaceMessages
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import type { CarplaceLanguage, OfferSummary } from './types'
|
||||
|
||||
export function formatOfferValue(offer: OfferSummary, language: CarplaceLanguage): string {
|
||||
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD', language)
|
||||
if (offer.type === 'FREE_DAY') {
|
||||
const days = Math.max(1, offer.discountValue)
|
||||
if (language === 'ar') return `${days} ${days === 1 ? 'يوم مجاني' : 'أيام مجانية'}`
|
||||
if (language === 'fr') return `${days} ${days === 1 ? 'jour offert' : 'jours offerts'}`
|
||||
return `${days} ${days === 1 ? 'free day' : 'free days'}`
|
||||
}
|
||||
if (offer.type === 'SPECIAL_RATE') {
|
||||
const rate = offer.specialRate ?? offer.discountValue
|
||||
const suffix = language === 'ar' ? '/يوم' : language === 'fr' ? '/jour' : '/day'
|
||||
return `${formatCurrency(rate, 'MAD', language)}${suffix}`
|
||||
}
|
||||
return `${offer.discountValue}%`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CARPLACE_BASE_PATH = '/carplace'
|
||||
|
||||
export function carplaceHref(path = ''): string {
|
||||
if (!path || path === '/') return CARPLACE_BASE_PATH
|
||||
return `${CARPLACE_BASE_PATH}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export function carplaceCompanyHref(slug: string): string {
|
||||
return carplaceHref(`/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function carplaceVehicleHref(slug: string, vehicleId: string, query?: URLSearchParams | string): string {
|
||||
const suffix = typeof query === 'string' ? query : query?.toString()
|
||||
return `${carplaceHref(`/${encodeURIComponent(slug)}/vehicles/${encodeURIComponent(vehicleId)}`)}${suffix ? `?${suffix}` : ''}`
|
||||
}
|
||||
|
||||
export function homepageHref(language: 'en' | 'fr' | 'ar'): string {
|
||||
return `/${language}`
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export type CarplaceLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
export type AvailabilityStatus = 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
|
||||
export interface CompanyBrand {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
subdomain: string
|
||||
publicCity?: string | null
|
||||
publicCountry?: string | null
|
||||
publicPhone?: string | null
|
||||
whatsappNumber?: string | null
|
||||
carplaceRating?: number | null
|
||||
primaryColor?: string | null
|
||||
description?: string | null
|
||||
coverImageUrl?: string | null
|
||||
}
|
||||
|
||||
export interface VehicleSummary {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
seats?: number | null
|
||||
transmission?: string | null
|
||||
fuelType?: string | null
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: AvailabilityStatus
|
||||
nextAvailableAt?: string | null
|
||||
company: {
|
||||
slug: string
|
||||
brand: CompanyBrand | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface VehicleSearchResponse {
|
||||
items: VehicleSummary[]
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
}
|
||||
facets: {
|
||||
categories: Array<{ value: string; count: number }>
|
||||
transmissions: Array<{ value: string; count: number }>
|
||||
makes: Array<{ value: string; count: number }>
|
||||
price: { min: number | null; max: number | null }
|
||||
}
|
||||
}
|
||||
|
||||
export interface OfferSummary {
|
||||
id: string
|
||||
title: string
|
||||
type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_DAY' | 'SPECIAL_RATE'
|
||||
specialRate?: number | null
|
||||
description?: string | null
|
||||
discountValue: number
|
||||
validUntil: string
|
||||
promoCode?: string | null
|
||||
company: {
|
||||
slug?: string
|
||||
brand: CompanyBrand | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompanySummary {
|
||||
id: string
|
||||
slug?: string
|
||||
name?: string
|
||||
brand: CompanyBrand | null
|
||||
_count: { vehicles: number }
|
||||
}
|
||||
|
||||
export interface CarplaceHomeResponse {
|
||||
cities: string[]
|
||||
offers: OfferSummary[]
|
||||
vehicles: VehicleSummary[]
|
||||
companies: CompanySummary[]
|
||||
}
|
||||
|
||||
export interface QuoteResponse {
|
||||
available: boolean
|
||||
availabilityStatus: AvailabilityStatus
|
||||
nextAvailableAt: string | null
|
||||
currency: 'MAD'
|
||||
rentalDays: number
|
||||
dailyRate: number
|
||||
baseSubtotal: number
|
||||
discounts: Array<{ label: string; amount: number }>
|
||||
fees: Array<{ label: string; amount: number }>
|
||||
taxes: Array<{ label: string; amount: number }>
|
||||
estimatedTotal: number
|
||||
securityDeposit: number | null
|
||||
finalPriceRequiresCompanyConfirmation: true
|
||||
}
|
||||
|
||||
export interface ReservationResult {
|
||||
reservationId: string
|
||||
bookingReference: string | null
|
||||
companyName: string
|
||||
vehicleName: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
status: 'PENDING'
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appPrivacyHref,
|
||||
appTermsHref,
|
||||
footerPageHref,
|
||||
footerPageSlugs,
|
||||
getFooterPageContent,
|
||||
isFooterPageSlug,
|
||||
} from './footerContent'
|
||||
import type { CarplaceLanguage } from './i18n'
|
||||
|
||||
const languages: CarplaceLanguage[] = ['en', 'fr', 'ar']
|
||||
|
||||
describe('carplace footer content registry', () => {
|
||||
it('keeps every declared slug routable and recognizable', () => {
|
||||
expect(footerPageSlugs.length).toBeGreaterThan(5)
|
||||
|
||||
for (const slug of footerPageSlugs) {
|
||||
expect(isFooterPageSlug(slug)).toBe(true)
|
||||
expect(footerPageHref[slug]).toBe(`/carplace/footer/${slug}`)
|
||||
}
|
||||
|
||||
expect(isFooterPageSlug('privacy')).toBe(false)
|
||||
expect(isFooterPageSlug('../admin')).toBe(false)
|
||||
})
|
||||
|
||||
it('provides localized title and paragraph content for every footer page', () => {
|
||||
for (const language of languages) {
|
||||
for (const slug of footerPageSlugs) {
|
||||
const content = getFooterPageContent(language, slug)
|
||||
|
||||
expect(content.title.trim()).not.toBe('')
|
||||
expect(content.paragraphs.length).toBeGreaterThan(0)
|
||||
expect(content.paragraphs.every((paragraph) => paragraph.trim().length > 0)).toBe(true)
|
||||
|
||||
for (const section of content.sections ?? []) {
|
||||
expect(section.heading.trim()).not.toBe('')
|
||||
expect(section.paragraphs.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps website privacy and application privacy links separate by locale', () => {
|
||||
expect(footerPageHref['privacy-policy']).toBe('/carplace/footer/privacy-policy')
|
||||
expect(appPrivacyHref).toEqual({
|
||||
en: '/carplace/app-privacy-en',
|
||||
fr: '/carplace/app-privacy-fr',
|
||||
ar: '/carplace/app-privacy-ar',
|
||||
})
|
||||
expect(appTermsHref).toEqual({
|
||||
en: '/carplace/app-tc-en',
|
||||
fr: '/carplace/app-tc-fr',
|
||||
ar: '/carplace/app-tc-ar',
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes footer policy copy that points users to the locale-matched app policy pages', () => {
|
||||
expect(getFooterPageContent('en', 'privacy-policy').paragraphs[0]).toContain('app-privacy-en')
|
||||
expect(getFooterPageContent('fr', 'privacy-policy').paragraphs[0]).toContain('app-privacy-fr')
|
||||
expect(getFooterPageContent('ar', 'privacy-policy').paragraphs[0]).toContain('app-privacy-ar')
|
||||
|
||||
expect(getFooterPageContent('en', 'general-conditions').paragraphs[0]).toContain('app-tc-en')
|
||||
expect(getFooterPageContent('fr', 'general-conditions').paragraphs[0]).toContain('app-tc-fr')
|
||||
expect(getFooterPageContent('ar', 'general-conditions').paragraphs[0]).toContain('app-tc-ar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,790 @@
|
||||
import type { CarplaceLanguage } from './i18n'
|
||||
|
||||
export const footerPageSlugs = [
|
||||
'about-us',
|
||||
'terms-of-service',
|
||||
'security',
|
||||
'compliance',
|
||||
'privacy-policy',
|
||||
'cookie-policy',
|
||||
'newsletter',
|
||||
'contact-sales',
|
||||
'general-conditions',
|
||||
] as const
|
||||
|
||||
export type FooterPageSlug = (typeof footerPageSlugs)[number]
|
||||
|
||||
export const appPrivacyHref = {
|
||||
en: '/carplace/app-privacy-en',
|
||||
fr: '/carplace/app-privacy-fr',
|
||||
ar: '/carplace/app-privacy-ar',
|
||||
} as const
|
||||
|
||||
export const appTermsHref = {
|
||||
en: '/carplace/app-tc-en',
|
||||
fr: '/carplace/app-tc-fr',
|
||||
ar: '/carplace/app-tc-ar',
|
||||
} as const
|
||||
|
||||
export type FooterPageContent = {
|
||||
title: string
|
||||
paragraphs: string[]
|
||||
sections?: Array<{
|
||||
heading: string
|
||||
paragraphs: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const footerPageHref: Record<FooterPageSlug, string> = {
|
||||
'about-us': '/carplace/footer/about-us',
|
||||
'terms-of-service': '/carplace/footer/terms-of-service',
|
||||
security: '/carplace/footer/security',
|
||||
compliance: '/carplace/footer/compliance',
|
||||
'privacy-policy': '/carplace/footer/privacy-policy',
|
||||
'cookie-policy': '/carplace/footer/cookie-policy',
|
||||
newsletter: '/carplace/footer/newsletter',
|
||||
'contact-sales': '/carplace/footer/contact-sales',
|
||||
'general-conditions': '/carplace/footer/general-conditions',
|
||||
}
|
||||
|
||||
const footerContent: Record<CarplaceLanguage, Record<FooterPageSlug, FooterPageContent>> = {
|
||||
en: {
|
||||
'about-us': {
|
||||
title: 'About Us',
|
||||
paragraphs: [
|
||||
'RentalDriveGo helps car rental businesses streamline operations, manage vehicle fleets, and grow reservations through one powerful platform. Our tools are designed to simplify fleet tracking, booking management, customer reservations, payments, and day-to-day rental operations so companies can focus on delivering exceptional service instead of drowning in spreadsheets and phone calls.',
|
||||
'Whether you operate a small local rental agency or a large multi-location fleet, RentalDriveGo provides the technology to help you scale efficiently and serve customers with confidence.',
|
||||
],
|
||||
},
|
||||
'terms-of-service': {
|
||||
title: 'Terms of Service',
|
||||
paragraphs: [
|
||||
'By using RentalDriveGo, you agree to comply with our platform policies, usage guidelines, and applicable laws. Our services are intended for legitimate car rental businesses and their authorized users. Users are responsible for maintaining account security, providing accurate business information, and using the platform responsibly.',
|
||||
'RentalDriveGo reserves the right to suspend accounts involved in fraudulent activity, misuse, or violations of these terms.',
|
||||
],
|
||||
},
|
||||
security: {
|
||||
title: 'Security',
|
||||
paragraphs: [
|
||||
'RentalDriveGo takes security seriously. We use modern encryption standards, secure cloud infrastructure, and continuous monitoring to help protect your business data, customer information, and reservation records.',
|
||||
'Our platform is built with security best practices to minimize risks and ensure reliable system performance.',
|
||||
],
|
||||
},
|
||||
compliance: {
|
||||
title: 'Compliance',
|
||||
paragraphs: [
|
||||
'RentalDriveGo is committed to operating in accordance with industry standards and applicable privacy and data protection regulations. We continuously review our systems and operational practices to maintain compliance, security, and transparency for our customers and partners.',
|
||||
],
|
||||
},
|
||||
'privacy-policy': {
|
||||
title: 'Privacy Policy',
|
||||
paragraphs: [
|
||||
'You are currently viewing the privacy policy for the RentalDriveGo website. To consult the application privacy policy, please visit: https://www.rentaldrivego.ma/app-privacy-en',
|
||||
'RentalDriveGo respects your privacy and is committed to handling your personal data in a transparent manner. This policy explains how we collect, use, store, and share your personal information when you use our website, and it describes your rights regarding the processing of your personal data.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Data Controller Contact Details',
|
||||
paragraphs: [
|
||||
'This privacy information applies to data processing carried out by RentalDriveGo.',
|
||||
'Telephone: +212 6 05 65 17 51',
|
||||
'Email: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Personal Data We Collect, How We Use It, and How Long We Keep It',
|
||||
paragraphs: [
|
||||
'When you browse our website for information only, without registering for a service or otherwise sending us personal data, your browser automatically sends certain technical information to our hosting provider, such as your browser type, language and version, the date and time of the request, your IP address, pages visited, referring website, volume of data transferred, and operating system.',
|
||||
'This information is temporarily stored to make the website available on your device, support internal analysis, improve the presentation of the website, and detect or prevent misuse. We do not combine this data with other data sources and we do not seek to identify you directly from it. This data is deleted as soon as it is no longer needed for these purposes.',
|
||||
'If you contact us by web form or email, we may collect your name, email address, telephone number, and any other information you choose to provide. We use this information to respond to your questions, contact you when relevant services or registrations become available, or manage participation in campaigns or competitions where applicable.',
|
||||
'We keep your personal data for as long as you maintain an account with us. If you want your personal data to be removed, you must submit a formal request, subject to any legal or contractual retention obligations that may apply.',
|
||||
'You may withdraw your consent at any time where processing is based on consent. Any processing carried out before withdrawal remains lawful.',
|
||||
'We may also use personal data for website review and improvement, market research, analysis, surveys, and marketing activities related to our products and services, subject to applicable law.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Third-Party Service Providers',
|
||||
paragraphs: [
|
||||
'We do not transfer your personal data to third parties unless we are legally required to do so, the transfer is necessary for the performance of a contractual relationship, or you have expressly agreed to it in advance.',
|
||||
'We may work with service providers in Morocco and abroad for technical and website-related services, including hosting and maintenance.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Security',
|
||||
paragraphs: [
|
||||
'The security of your personal data is important to us. We apply appropriate organizational, technical, and physical measures to protect the personal information we collect, both during transmission and after receipt.',
|
||||
'Our security measures and privacy practices are reviewed and improved on an ongoing basis.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Retention of Personal Data',
|
||||
paragraphs: [
|
||||
'We keep personal data only for as long as we are legally entitled to do so, as long as the purpose of processing continues, and provided that no valid objection has been raised.',
|
||||
'Where legal retention periods apply, the data will be stored for the required duration. Once the applicable period has expired, the data will be deleted unless it is still needed for the performance or preparation of a contract.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Transfer to a Foreign Country',
|
||||
paragraphs: [
|
||||
'The arrangements described above may result in your personal data being stored or processed in foreign countries.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Your Rights',
|
||||
paragraphs: [
|
||||
'Under Law No. 09-08 relating to the protection of individuals with regard to the processing of personal data, you may have the right to access your personal data, obtain additional information, request correction of inaccurate data, and object, where applicable, to the processing of your data or to its transfer to third parties.',
|
||||
'To exercise your rights, you may send a request including your full name and a copy of your identity document to: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-policy': {
|
||||
title: 'Cookie Policy',
|
||||
paragraphs: [
|
||||
'We use cookies exclusively to operate the platform and remember your personal preferences. We do not use cookies for advertising, cross-site tracking, or behavioural profiling, and we do not share cookie data with third parties for marketing purposes.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Authentication Cookie',
|
||||
paragraphs: [
|
||||
'Name: employee_session',
|
||||
'This cookie is set when you sign in to the RentalDriveGo workspace. It stores a cryptographically signed token that verifies your identity for the duration of your session. It expires automatically after 8 hours and is deleted when you sign out. This cookie is strictly necessary — without it, access to the dashboard and protected areas is not possible.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Language Preference Cookie',
|
||||
paragraphs: [
|
||||
'Name: rentaldrivego-language',
|
||||
'Stores your chosen display language (English, French, or Arabic) so the interface appears in your preferred language on every visit across all parts of the platform. It is retained for one year.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Theme Preference Cookie',
|
||||
paragraphs: [
|
||||
'Name: rentaldrivego-theme',
|
||||
'Stores your preferred colour scheme (light or dark mode). It is read immediately when a page loads to apply the correct theme before the interface is drawn, preventing a visual flash. It is retained for one year.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Per-User Preference Cookies',
|
||||
paragraphs: [
|
||||
'Names: rentaldrivego-language--[id], rentaldrivego-theme--[id]',
|
||||
'When you are signed in, your language and theme preferences are also saved under a cookie linked to your account identifier. This allows multiple employees sharing a device to each maintain independent preferences. These cookies are retained for one year.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Preference Cookies',
|
||||
paragraphs: [
|
||||
'Names: dashboard-language, carplace-language',
|
||||
'These cookies exist for backward compatibility with earlier versions of the platform and store the same language preference as the primary cookie. They are read only as a fallback and will be phased out in a future update.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Managing Your Cookies',
|
||||
paragraphs: [
|
||||
'You can manage or delete cookies at any time through your browser settings. Blocking the authentication cookie will prevent you from signing in. Blocking preference cookies will cause your language and theme settings to reset on each visit. For general information about cookies, you may visit http://www.allaboutcookies.org/.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
newsletter: {
|
||||
title: 'Newsletter',
|
||||
paragraphs: [
|
||||
'Stay updated with the latest industry insights, product updates, feature releases, and fleet management tips from RentalDriveGo.',
|
||||
'Subscribe to our newsletter and receive helpful resources designed to help car rental businesses grow smarter and operate more efficiently.',
|
||||
],
|
||||
},
|
||||
'contact-sales': {
|
||||
title: 'Contact Sales',
|
||||
paragraphs: [
|
||||
'Want to see how RentalDriveGo can transform your rental operations? Our sales team is ready to help you find the right solution for your business.',
|
||||
'Contact us to schedule a demo, discuss pricing, or learn more about our fleet management and reservation platform.',
|
||||
],
|
||||
},
|
||||
'general-conditions': {
|
||||
title: 'General Conditions of Use',
|
||||
paragraphs: [
|
||||
'You are currently viewing the General Conditions of Use for the RentalDriveGo website. To consult the General Conditions of Use of the application, please visit: https://www.rentaldrivego.ma/app-tc-en',
|
||||
'These General Conditions of Use define the rules that apply when you visit, access, browse, contact us through, or otherwise interact with the website www.rentaldrivego.ma.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Purpose',
|
||||
paragraphs: [
|
||||
'These conditions govern the use of the website www.rentaldrivego.ma, operated by RentalDriveGo, a limited liability company registered in the Azrou Trade Register under number 00000, with its registered office in Azrou',
|
||||
'They also define the standards relating to the content of the website and apply to any person who visits the website, contacts us or other users through it, links to it, or interacts with it in any way.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Website Offer',
|
||||
paragraphs: [
|
||||
'We operate the website solely for information, prospecting, and market research purposes.',
|
||||
'Information relating to the RentalDriveGo loyalty program may be presented to visitors, and download links may be made available through the website.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Contact',
|
||||
paragraphs: [
|
||||
'You may contact us through the website or by email at service-client@rentaldrivego.ma.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Acceptance of the General Conditions of Use',
|
||||
paragraphs: [
|
||||
'By using the website, you accept these General Conditions of Use and agree to comply with them. If you do not accept them, you must not use the website.',
|
||||
'Our Privacy Policy and Cookie Policy also apply to your use of the website.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Changes to the General Conditions of Use',
|
||||
paragraphs: [
|
||||
'We may amend these General Conditions of Use at any time. Each time you use the website, you should review the current version.',
|
||||
'We also reserve the right to amend, correct, or update any information published on the website at any time.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Changes to Website Content and Availability',
|
||||
paragraphs: [
|
||||
'We may update or modify the content of the website at any time to reflect changes in the needs of users and partners.',
|
||||
'The website is made available free of charge. While we use reasonable means to prevent and resolve interruptions, we cannot guarantee continuous or uninterrupted availability.',
|
||||
'We may suspend, withdraw, or restrict the availability of all or part of the website for commercial or operational reasons.',
|
||||
'You are responsible for ensuring that all persons accessing the website through your internet connection are aware of these conditions and comply with them.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Scope of Information on the Website',
|
||||
paragraphs: [
|
||||
'The content of the website is provided for general information purposes only and does not constitute a recommendation on which you should rely.',
|
||||
'Although we make reasonable efforts to update the information on the website, we do not guarantee, whether expressly or implicitly, that the content is accurate, complete, or current.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '8. Third-Party Websites',
|
||||
paragraphs: [
|
||||
'We are not responsible for third-party websites to which the website may link.',
|
||||
'Any hyperlinks to third-party content, websites, or resources are provided for information purposes only, and we cannot be held responsible for their content, accuracy, or quality.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '9. Limitation of Liability',
|
||||
paragraphs: [
|
||||
'You agree that access to and use of the website and any associated pages is at your own risk.',
|
||||
'Nothing in these conditions excludes or limits our liability where such exclusion or limitation would be unlawful.',
|
||||
'Neither we nor our partners or affiliates may be held liable for damage resulting from the use of information, files, links, or websites accessible from or received through the website.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '10. Your Responsibilities',
|
||||
paragraphs: [
|
||||
'You acknowledge that we may use the information you provide to us where you have given the required consent or where another lawful basis applies.',
|
||||
'You agree to use the website only for lawful purposes.',
|
||||
'If we consider that a breach of these General Conditions of Use has occurred, we may take any action we deem appropriate.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '11. Intellectual Property',
|
||||
paragraphs: [
|
||||
'The website is our property and the entire website is protected by intellectual property laws.',
|
||||
'All rights of reproduction and representation are reserved.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
'about-us': {
|
||||
title: 'À propos de nous',
|
||||
paragraphs: [
|
||||
'RentalDriveGo aide les entreprises de location de voitures à simplifier leurs opérations, gérer leurs flottes et augmenter leurs réservations grâce à une plateforme complète. Nos outils permettent de gérer les véhicules, les réservations, les paiements et les opérations quotidiennes de manière efficace et centralisée.',
|
||||
'Que vous soyez une petite agence locale ou une grande entreprise multi-sites, RentalDriveGo vous aide à développer votre activité avec des solutions modernes et fiables.',
|
||||
],
|
||||
},
|
||||
'terms-of-service': {
|
||||
title: 'Conditions d’utilisation',
|
||||
paragraphs: [
|
||||
'En utilisant RentalDriveGo, vous acceptez de respecter nos politiques, nos règles d’utilisation et les lois applicables. Nos services sont destinés aux entreprises de location de voitures et à leurs utilisateurs autorisés.',
|
||||
'RentalDriveGo se réserve le droit de suspendre tout compte impliqué dans une activité frauduleuse ou une violation des présentes conditions.',
|
||||
],
|
||||
},
|
||||
security: {
|
||||
title: 'Sécurité',
|
||||
paragraphs: [
|
||||
'RentalDriveGo applique des standards de sécurité modernes afin de protéger les données de votre entreprise, les informations de vos clients et vos réservations.',
|
||||
'Notre plateforme utilise le chiffrement, une infrastructure cloud sécurisée et une surveillance continue afin d’assurer la confidentialité et la fiabilité des services.',
|
||||
],
|
||||
},
|
||||
compliance: {
|
||||
title: 'Conformité',
|
||||
paragraphs: [
|
||||
'RentalDriveGo s’engage à respecter les réglementations applicables ainsi que les standards du secteur concernant la protection des données et la sécurité.',
|
||||
'Nous améliorons continuellement nos systèmes afin de garantir transparence, conformité et confiance à nos clients.',
|
||||
],
|
||||
},
|
||||
'privacy-policy': {
|
||||
title: 'Politique de confidentialité',
|
||||
paragraphs: [
|
||||
'Vous consultez actuellement la politique de confidentialité du site RentalDriveGo. Pour consulter la politique de confidentialité de l’application, veuillez visiter : https://www.rentaldrivego.ma/app-privacy-fr',
|
||||
'RentalDriveGo respecte votre vie privée et s’engage à traiter vos données personnelles de manière transparente. Cette politique explique comment nous collectons, utilisons, conservons et partageons vos informations personnelles lorsque vous utilisez notre site internet, ainsi que les droits dont vous disposez concernant ce traitement.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Coordonnées du responsable du traitement',
|
||||
paragraphs: [
|
||||
'Les présentes informations sur la protection des données s’appliquent aux traitements effectués par RentalDriveGo.',
|
||||
'Téléphone : +212 6 05 65 17 51',
|
||||
'Email : service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Données personnelles collectées, finalités d’utilisation et durée de conservation',
|
||||
paragraphs: [
|
||||
'Lorsque vous consultez notre site à des fins d’information uniquement, sans vous inscrire à un service et sans nous transmettre de données personnelles par un autre moyen, votre navigateur envoie automatiquement certaines informations techniques à notre hébergeur, notamment le type, la langue et la version du navigateur, la date et l’heure de la demande, l’adresse IP, les pages consultées, le site d’origine, le volume de données transférées et le système d’exploitation.',
|
||||
'Ces informations sont temporairement conservées afin de permettre l’affichage du site sur votre appareil, de réaliser des analyses internes, d’améliorer la présentation du site et de détecter ou prévenir les abus. Nous ne croisons pas ces données avec d’autres sources et nous ne cherchons pas à vous identifier directement à partir de celles-ci. Elles sont supprimées dès qu’elles ne sont plus nécessaires à ces finalités.',
|
||||
'Si vous nous contactez via un formulaire ou par email, nous pouvons collecter votre nom, votre adresse email, votre numéro de téléphone et toute autre information que vous choisissez de nous communiquer. Nous utilisons ces données pour répondre à vos questions, vous contacter lorsque certains services ou inscriptions deviennent disponibles, ou gérer votre participation à des campagnes ou concours le cas échéant.',
|
||||
'Nous conservons vos données personnelles aussi longtemps que vous détenez un compte chez nous. Si vous souhaitez la suppression de vos données personnelles, vous devez nous adresser une demande formelle, sous réserve de toute obligation légale ou contractuelle de conservation applicable.',
|
||||
'Vous pouvez retirer votre consentement à tout moment lorsque le traitement repose sur celui-ci. Les traitements effectués avant ce retrait demeurent licites.',
|
||||
'Nous pouvons également utiliser les données personnelles pour l’évaluation et l’amélioration du site, les études de marché, les analyses, les enquêtes ainsi que les actions de marketing liées à nos produits et services, dans le respect de la loi applicable.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Prestataires tiers',
|
||||
paragraphs: [
|
||||
'Nous ne communiquons pas vos données personnelles à des tiers sauf en cas d’obligation légale, lorsque ce transfert est nécessaire à l’exécution d’une relation contractuelle, ou lorsque vous y avez expressément consenti au préalable.',
|
||||
'Nous pouvons travailler avec des prestataires situés au Maroc et à l’étranger pour des services techniques et liés au site internet, notamment l’hébergement et la maintenance.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Sécurité',
|
||||
paragraphs: [
|
||||
'La sécurité de vos données personnelles est importante pour nous. Nous mettons en place des mesures organisationnelles, techniques et physiques appropriées afin de protéger les informations personnelles collectées, aussi bien pendant leur transmission qu’après leur réception.',
|
||||
'Nos mesures de sécurité et nos pratiques de confidentialité sont revues et améliorées de manière continue.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Conservation des données personnelles',
|
||||
paragraphs: [
|
||||
'Nous conservons les données personnelles uniquement aussi longtemps que la loi nous y autorise, tant que la finalité du traitement subsiste et en l’absence d’opposition valable.',
|
||||
'Lorsque des délais légaux de conservation s’appliquent, les données sont conservées pendant la durée requise. À l’expiration de cette période, elles sont supprimées sauf si elles restent nécessaires à l’exécution ou à la préparation d’un contrat.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Transfert vers un pays étranger',
|
||||
paragraphs: [
|
||||
'Les traitements décrits ci-dessus peuvent entraîner le stockage ou le traitement de vos données personnelles dans des pays étrangers.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Vos droits',
|
||||
paragraphs: [
|
||||
'Conformément à la loi n° 09-08 relative à la protection des personnes physiques à l’égard du traitement des données à caractère personnel, vous pouvez disposer d’un droit d’accès à vos données, d’un droit d’obtenir des informations complémentaires, d’un droit de rectification des données inexactes et, le cas échéant, d’un droit d’opposition au traitement ou au transfert de vos données à des tiers.',
|
||||
'Pour exercer vos droits, vous pouvez envoyer une demande indiquant votre nom complet et accompagnée d’une copie de votre pièce d’identité à : service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-policy': {
|
||||
title: 'Politique relative aux cookies',
|
||||
paragraphs: [
|
||||
'Nous utilisons des cookies exclusivement pour assurer le fonctionnement de la plateforme et mémoriser vos préférences personnelles. Nous n’utilisons pas de cookies à des fins publicitaires, de suivi intersites ou de profilage comportemental, et nous ne partageons pas les données des cookies avec des tiers à des fins marketing.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Cookie d’authentification',
|
||||
paragraphs: [
|
||||
'Nom : employee_session',
|
||||
'Ce cookie est défini lorsque vous vous connectez à l’espace de travail RentalDriveGo. Il stocke un jeton cryptographiquement signé qui vérifie votre identité pour la durée de votre session. Il expire automatiquement après 8 heures et est supprimé lors de la déconnexion. Ce cookie est strictement nécessaire — sans lui, l’accès au tableau de bord et aux espaces protégés n’est pas possible.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Cookie de préférence de langue',
|
||||
paragraphs: [
|
||||
'Nom : rentaldrivego-language',
|
||||
'Enregistre la langue d’affichage choisie (français, anglais ou arabe) afin que l’interface s’affiche dans votre langue préférée à chaque visite sur l’ensemble de la plateforme. Il est conservé pendant un an.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Cookie de préférence de thème',
|
||||
paragraphs: [
|
||||
'Nom : rentaldrivego-theme',
|
||||
'Enregistre le schéma de couleurs préféré (mode clair ou sombre). Il est lu dès le chargement de la page afin d’appliquer le bon thème avant l’affichage de l’interface, évitant ainsi un changement visuel brusque. Il est conservé pendant un an.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Cookies de préférence par utilisateur',
|
||||
paragraphs: [
|
||||
'Noms : rentaldrivego-language--[id], rentaldrivego-theme--[id]',
|
||||
'Lorsque vous êtes connecté, vos préférences de langue et de thème sont également enregistrées dans un cookie lié à votre identifiant de compte. Cela permet à plusieurs employés partageant un appareil de conserver des préférences indépendantes. Ces cookies sont conservés pendant un an.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Cookies de préférence',
|
||||
paragraphs: [
|
||||
'Noms : dashboard-language, carplace-language',
|
||||
'Ces cookies existent pour assurer la compatibilité ascendante avec les versions antérieures de la plateforme et stockent la même préférence de langue que le cookie principal. Ils ne sont lus qu’en secours et seront supprimés dans une prochaine mise à jour.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Gestion de vos cookies',
|
||||
paragraphs: [
|
||||
'Vous pouvez gérer ou supprimer les cookies à tout moment via les paramètres de votre navigateur. Bloquer le cookie d’authentification vous empêchera de vous connecter. Bloquer les cookies de préférence entraînera la réinitialisation de vos paramètres de langue et de thème à chaque visite. Pour des informations générales sur les cookies, vous pouvez consulter http://www.allaboutcookies.org/.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
newsletter: {
|
||||
title: 'Newsletter',
|
||||
paragraphs: [
|
||||
'Recevez les dernières actualités, mises à jour produits, conseils de gestion de flotte et nouveautés de RentalDriveGo.',
|
||||
'Abonnez-vous à notre newsletter pour rester informé et développer votre activité plus efficacement.',
|
||||
],
|
||||
},
|
||||
'contact-sales': {
|
||||
title: 'Contacter les ventes',
|
||||
paragraphs: [
|
||||
'Vous souhaitez découvrir comment RentalDriveGo peut améliorer vos opérations de location de voitures ?',
|
||||
'Notre équipe commerciale est disponible pour organiser une démonstration, répondre à vos questions et vous proposer la solution adaptée à votre entreprise.',
|
||||
],
|
||||
},
|
||||
'general-conditions': {
|
||||
title: 'Conditions générales d’utilisation',
|
||||
paragraphs: [
|
||||
'Vous consultez actuellement les Conditions générales d’utilisation du site RentalDriveGo. Pour consulter les Conditions générales d’utilisation de l’application, veuillez visiter : https://www.rentaldrivego.ma/app-tc-fr',
|
||||
'Les présentes Conditions générales d’utilisation définissent les règles applicables lorsque vous visitez, accédez, parcourez, contactez ou utilisez de toute autre manière le site www.rentaldrivego.ma.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Objet',
|
||||
paragraphs: [
|
||||
'Les présentes conditions régissent l’utilisation du site www.rentaldrivego.ma exploité par RentalDriveGo, société à responsabilité limitée immatriculée au registre du commerce d’Azrou sous le numéro 00000, dont le siège social est situé à Azrou.',
|
||||
'Elles définissent également les règles relatives au contenu du site et s’appliquent à toute personne qui visite le site, nous contacte ou contacte d’autres utilisateurs par son intermédiaire, y crée un lien ou interagit avec lui de quelque manière que ce soit.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Offre du site',
|
||||
paragraphs: [
|
||||
'Nous exploitons le site exclusivement à des fins d’information, de prospection et d’étude de marché.',
|
||||
'Des informations relatives au programme de fidélité RentalDriveGo peuvent être portées à la connaissance des visiteurs et des liens de téléchargement peuvent être mis à leur disposition.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Contact',
|
||||
paragraphs: [
|
||||
'Vous pouvez nous contacter via le site ou par email à l’adresse service-client@rentaldrivego.ma.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Acceptation des Conditions générales d’utilisation',
|
||||
paragraphs: [
|
||||
'En utilisant le site, vous acceptez les présentes Conditions générales d’utilisation et vous vous engagez à les respecter. Si vous ne les acceptez pas, vous ne devez pas utiliser le site.',
|
||||
'Notre Politique de confidentialité et notre Politique relative aux cookies s’appliquent également à votre utilisation du site.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Modification des Conditions générales d’utilisation',
|
||||
paragraphs: [
|
||||
'Nous pouvons modifier à tout moment les présentes Conditions générales d’utilisation. À chaque utilisation du site, nous vous invitons à consulter la version en vigueur.',
|
||||
'Nous nous réservons également le droit de modifier, corriger ou mettre à jour à tout moment les informations publiées sur le site.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Modification du contenu et disponibilité du site',
|
||||
paragraphs: [
|
||||
'Nous pouvons mettre à jour ou modifier le contenu du site à tout moment afin de refléter l’évolution des besoins de nos utilisateurs et partenaires.',
|
||||
'Le site est mis à votre disposition gratuitement. Bien que nous mettions en œuvre des moyens raisonnables pour prévenir et résoudre les interruptions, nous ne pouvons garantir une disponibilité constante et ininterrompue.',
|
||||
'Nous pouvons suspendre, retirer ou restreindre l’accès à tout ou partie du site pour des raisons commerciales ou opérationnelles.',
|
||||
'Il vous appartient de veiller à ce que toute personne accédant au site par votre connexion internet ait connaissance des présentes conditions et les respecte.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Portée des informations figurant sur le site',
|
||||
paragraphs: [
|
||||
'Le contenu du site est fourni à titre d’information générale uniquement et ne constitue ni un conseil ni une recommandation sur lesquels vous devriez vous appuyer.',
|
||||
'Bien que nous fassions des efforts raisonnables pour mettre à jour les informations publiées sur le site, nous ne garantissons pas, expressément ou implicitement, qu’elles soient exactes, complètes ou à jour.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '8. Responsabilité à l’égard des sites tiers',
|
||||
paragraphs: [
|
||||
'Nous ne sommes pas responsables des sites internet tiers vers lesquels le site peut contenir des liens.',
|
||||
'Les liens hypertextes vers des contenus, sites ou ressources de tiers sont fournis uniquement à titre informatif, et nous ne pouvons être tenus responsables de leur contenu, de leur exactitude ou de leur qualité.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '9. Limitation de responsabilité',
|
||||
paragraphs: [
|
||||
'Vous reconnaissez que l’accès et l’utilisation du site ainsi que des pages qui y sont rattachées se font à vos propres risques.',
|
||||
'Aucune disposition des présentes conditions n’exclut ou ne limite notre responsabilité lorsqu’une telle exclusion ou limitation serait contraire à la loi.',
|
||||
'Ni nous, ni nos partenaires ou affiliés, ne pourrons être tenus responsables des dommages résultant de l’utilisation des informations, fichiers, liens ou sites accessibles depuis le site ou reçus par son intermédiaire.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '10. Vos responsabilités',
|
||||
paragraphs: [
|
||||
'Vous reconnaissez que nous pouvons utiliser les informations que vous nous fournissez lorsque vous avez donné le consentement requis ou lorsqu’une autre base légale s’applique.',
|
||||
'Vous vous engagez à utiliser le site uniquement à des fins licites.',
|
||||
'Si nous estimons qu’une violation des présentes Conditions générales d’utilisation a eu lieu, nous pouvons prendre toute mesure que nous jugeons appropriée.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '11. Propriété intellectuelle',
|
||||
paragraphs: [
|
||||
'Le site est notre propriété et l’ensemble du site est protégé par la législation relative à la propriété intellectuelle.',
|
||||
'Tous les droits de reproduction et de représentation sont réservés.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
ar: {
|
||||
'about-us': {
|
||||
title: 'من نحن',
|
||||
paragraphs: [
|
||||
'تساعد منصة RentalDriveGo شركات تأجير السيارات على إدارة عملياتها بسهولة، وتنظيم أساطيلها، وزيادة الحجوزات من خلال منصة متكاملة وحديثة. توفر أدواتنا حلولاً لإدارة المركبات والحجوزات والمدفوعات والعمليات اليومية بكفاءة عالية.',
|
||||
'سواء كنت تدير شركة محلية صغيرة أو أسطولاً كبيراً متعدد الفروع، فإن RentalDriveGo توفر لك التكنولوجيا التي تساعدك على النمو وإدارة أعمالك بثقة.',
|
||||
],
|
||||
},
|
||||
'terms-of-service': {
|
||||
title: 'شروط الاستخدام',
|
||||
paragraphs: [
|
||||
'باستخدامك لمنصة RentalDriveGo، فإنك توافق على الالتزام بسياسات المنصة والقوانين المعمول بها. خدماتنا مخصصة لشركات تأجير السيارات والمستخدمين المصرح لهم فقط.',
|
||||
'تحتفظ RentalDriveGo بحق تعليق أي حساب يشارك في أنشطة احتيالية أو يخالف شروط الاستخدام.',
|
||||
],
|
||||
},
|
||||
security: {
|
||||
title: 'الأمان',
|
||||
paragraphs: [
|
||||
'تلتزم RentalDriveGo بتوفير أعلى معايير الأمان لحماية بيانات شركتك ومعلومات عملائك وسجلات الحجوزات.',
|
||||
'تستخدم منصتنا تقنيات تشفير حديثة وبنية سحابية آمنة وأنظمة مراقبة مستمرة لضمان حماية البيانات واستقرار الخدمة.',
|
||||
],
|
||||
},
|
||||
compliance: {
|
||||
title: 'الامتثال',
|
||||
paragraphs: [
|
||||
'تلتزم RentalDriveGo بالامتثال للمعايير واللوائح المعمول بها المتعلقة بحماية البيانات والخصوصية والأمان.',
|
||||
'نعمل باستمرار على تطوير أنظمتنا وإجراءاتنا لضمان الشفافية والثقة لعملائنا وشركائنا.',
|
||||
],
|
||||
},
|
||||
'privacy-policy': {
|
||||
title: 'سياسة الخصوصية',
|
||||
paragraphs: [
|
||||
'أنت الآن تطّلع على سياسة الخصوصية الخاصة بموقع RentalDriveGo. للاطلاع على سياسة الخصوصية الخاصة بالتطبيق، يرجى زيارة: https://www.rentaldrivego.ma/app-privacy-ar',
|
||||
'تحترم RentalDriveGo خصوصيتك وتلتزم بالتعامل مع بياناتك الشخصية بشفافية. توضح هذه السياسة كيفية جمع معلوماتك الشخصية واستخدامها وتخزينها ومشاركتها عند استخدامك لموقعنا، كما تشرح حقوقك المتعلقة بمعالجة بياناتك الشخصية.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. بيانات الاتصال الخاصة بمسؤول المعالجة',
|
||||
paragraphs: [
|
||||
'تنطبق هذه المعلومات المتعلقة بحماية البيانات على المعالجة التي تقوم بها RentalDriveGo.',
|
||||
'الهاتف: +212 6 05 65 17 51',
|
||||
'البريد الإلكتروني: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. ما هي البيانات الشخصية التي نجمعها، وكيف نستخدمها، ومدة الاحتفاظ بها',
|
||||
paragraphs: [
|
||||
'عند زيارتك لموقعنا لأغراض الاطلاع فقط، من دون التسجيل في أي خدمة أو تزويدنا ببيانات شخصية بأي وسيلة أخرى، يرسل متصفحك تلقائياً بعض المعلومات التقنية إلى مزود الاستضافة، مثل نوع المتصفح ولغته وإصداره، وتاريخ ووقت الطلب، وعنوان IP، والصفحات التي تمت زيارتها، والموقع المحيل، وحجم البيانات المنقولة، ونظام التشغيل.',
|
||||
'يتم الاحتفاظ بهذه المعلومات مؤقتاً لتمكين عرض الموقع على جهازك، ودعم التحليل الداخلي، وتحسين طريقة عرض الموقع، واكتشاف أو منع أي إساءة استخدام. نحن لا ندمج هذه البيانات مع مصادر أخرى ولا نسعى إلى التعرف المباشر على هويتك من خلالها. ويتم حذفها بمجرد انتفاء الحاجة إليها لهذه الأغراض.',
|
||||
'إذا تواصلت معنا عبر نموذج إلكتروني أو عبر البريد الإلكتروني، فقد نجمع اسمك وبريدك الإلكتروني ورقم هاتفك وأي معلومات أخرى تختار تقديمها لنا. نستخدم هذه البيانات للرد على استفساراتك، أو التواصل معك عندما تصبح بعض الخدمات أو التسجيلات متاحة، أو لإدارة مشاركتك في الحملات أو المسابقات عند الاقتضاء.',
|
||||
'نحتفظ ببياناتك الشخصية طوال الفترة التي يكون لديك فيها حساب لدينا. وإذا كنت ترغب في حذف بياناتك الشخصية، فيجب عليك تقديم طلب رسمي بذلك، مع مراعاة أي التزامات قانونية أو تعاقدية قد تفرض الاحتفاظ ببعض البيانات.',
|
||||
'يمكنك سحب موافقتك في أي وقت عندما تكون المعالجة قائمة على الموافقة. وتظل المعالجة التي تمت قبل سحب الموافقة مشروعة.',
|
||||
'وقد نستخدم أيضاً البيانات الشخصية لمراجعة الموقع وتحسينه، وإجراء أبحاث السوق، والتحليلات، والاستبيانات، والأنشطة التسويقية المتعلقة بمنتجاتنا وخدماتنا، وفقاً للقانون المعمول به.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. مزودو الخدمات من الأطراف الثالثة',
|
||||
paragraphs: [
|
||||
'لا نقوم بنقل بياناتك الشخصية إلى أطراف ثالثة إلا إذا كان ذلك مطلوباً بموجب القانون، أو كان ضرورياً لتنفيذ علاقة تعاقدية، أو إذا كنت قد وافقت على ذلك صراحة مسبقاً.',
|
||||
'قد نتعاون مع مزودي خدمات في المغرب وخارجه لتقديم الخدمات التقنية والخدمات المرتبطة بالموقع، بما في ذلك الاستضافة والصيانة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. الأمان',
|
||||
paragraphs: [
|
||||
'تمثل حماية بياناتك الشخصية أولوية بالنسبة لنا. لذلك نعتمد تدابير تنظيمية وتقنية ومادية مناسبة لحماية المعلومات الشخصية التي نجمعها، سواء أثناء نقلها أو بعد استلامها.',
|
||||
'نقوم بمراجعة وتحسين إجراءات الأمان وممارسات الخصوصية لدينا بشكل مستمر.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. الاحتفاظ بالبيانات الشخصية',
|
||||
paragraphs: [
|
||||
'نحتفظ بالبيانات الشخصية فقط للمدة التي يسمح بها القانون، وطالما أن الغرض من المعالجة لا يزال قائماً، وما لم يتم تقديم اعتراض مشروع.',
|
||||
'وعندما تكون هناك مدد قانونية للاحتفاظ بالبيانات، فإننا نلتزم بها. وبعد انتهاء المدة المعمول بها، يتم حذف البيانات ما لم تعد هناك حاجة إليها لتنفيذ عقد أو لاتخاذ خطوات تمهيدية لإبرام عقد.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. النقل إلى دولة أجنبية',
|
||||
paragraphs: [
|
||||
'قد تؤدي الترتيبات المذكورة أعلاه إلى تخزين بياناتك الشخصية أو معالجتها في دول أجنبية.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. حقوقك',
|
||||
paragraphs: [
|
||||
'وفقاً للقانون رقم 09-08 المتعلق بحماية الأشخاص تجاه معالجة المعطيات ذات الطابع الشخصي، قد يكون لك الحق في الوصول إلى بياناتك، والحصول على معلومات إضافية، وطلب تصحيح البيانات غير الدقيقة، والاعتراض عند الاقتضاء على معالجة بياناتك أو على نقلها إلى أطراف ثالثة.',
|
||||
'لممارسة حقوقك، يمكنك إرسال طلب يتضمن اسمك الكامل ونسخة من وثيقة هويتك إلى: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-policy': {
|
||||
title: 'سياسة ملفات تعريف الارتباط',
|
||||
paragraphs: [
|
||||
'نستخدم ملفات تعريف الارتباط حصرياً لتشغيل المنصة وتذكّر تفضيلاتك الشخصية. لا نستخدم ملفات تعريف الارتباط لأغراض إعلانية أو تتبع المستخدمين عبر المواقع أو بناء ملفات تعريف سلوكية، ولا نشارك بيانات ملفات تعريف الارتباط مع أطراف ثالثة لأغراض تسويقية.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. ملف تعريف الارتباط للمصادقة',
|
||||
paragraphs: [
|
||||
'الاسم: employee_session',
|
||||
'يُعيَّن هذا الملف عند تسجيل دخولك إلى مساحة عمل RentalDriveGo. يخزّن رمزاً موقّعاً تشفيرياً يُثبت هويتك طوال مدة الجلسة. تنتهي صلاحيته تلقائياً بعد 8 ساعات ويُحذف عند تسجيل الخروج. هذا الملف ضروري بشكل مطلق — إذ لا يمكن الوصول إلى لوحة التحكم والمناطق المحمية بدونه.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. ملف تعريف الارتباط لتفضيل اللغة',
|
||||
paragraphs: [
|
||||
'الاسم: rentaldrivego-language',
|
||||
'يحفظ لغة العرض التي اخترتها (العربية أو الفرنسية أو الإنجليزية) حتى تظهر الواجهة بلغتك المفضّلة في كل زيارة وعبر جميع أجزاء المنصة. يُحتفظ به لمدة سنة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. ملف تعريف الارتباط لتفضيل المظهر',
|
||||
paragraphs: [
|
||||
'الاسم: rentaldrivego-theme',
|
||||
'يحفظ نظام الألوان المفضّل لديك (الوضع الفاتح أو الداكن). يُقرأ فور تحميل الصفحة لتطبيق المظهر الصحيح قبل عرض الواجهة، ما يمنع حدوث وميض بصري غير مرغوب. يُحتفظ به لمدة سنة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. ملفات تعريف الارتباط للتفضيلات الخاصة بكل مستخدم',
|
||||
paragraphs: [
|
||||
'الأسماء: rentaldrivego-language--[id], rentaldrivego-theme--[id]',
|
||||
'عند تسجيل دخولك، تُحفظ تفضيلات اللغة والمظهر الخاصة بك أيضاً في ملف مرتبط بمعرّف حسابك. يتيح ذلك لأكثر من موظف يتشاركون جهازاً واحداً الاحتفاظ بتفضيلات مستقلة. يُحتفظ بهذه الملفات لمدة سنة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. ملفات تعريف الارتباط الموروثة',
|
||||
paragraphs: [
|
||||
'الأسماء: dashboard-language, carplace-language',
|
||||
'توجد هذه الملفات لضمان التوافق مع الإصدارات السابقة من المنصة، وتخزّن تفضيل اللغة ذاته الموجود في الملف الرئيسي. لا تُقرأ إلا كخيار احتياطي، وستُزال في تحديث مستقبلي.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. إدارة ملفات تعريف الارتباط',
|
||||
paragraphs: [
|
||||
'يمكنك إدارة ملفات تعريف الارتباط أو حذفها في أي وقت من خلال إعدادات متصفحك. سيمنعك حظر ملف المصادقة من تسجيل الدخول. أما حظر ملفات التفضيلات فسيؤدي إلى إعادة ضبط إعدادات اللغة والمظهر في كل زيارة. للاطلاع على معلومات عامة حول ملفات تعريف الارتباط، يمكنك زيارة http://www.allaboutcookies.org/.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
newsletter: {
|
||||
title: 'النشرة الإخبارية',
|
||||
paragraphs: [
|
||||
'ابقَ على اطلاع بآخر تحديثات المنصة وأخبار القطاع ونصائح إدارة الأساطيل من RentalDriveGo.',
|
||||
'اشترك في نشرتنا الإخبارية للحصول على محتوى مفيد يساعدك على تطوير أعمالك بكفاءة أكبر.',
|
||||
],
|
||||
},
|
||||
'contact-sales': {
|
||||
title: 'تواصل مع المبيعات',
|
||||
paragraphs: [
|
||||
'هل ترغب في معرفة كيف يمكن لـ RentalDriveGo تطوير عمليات شركتك؟',
|
||||
'فريق المبيعات لدينا جاهز لتقديم عرض توضيحي والإجابة على استفساراتك ومساعدتك في اختيار الحل المناسب لاحتياجاتك.',
|
||||
],
|
||||
},
|
||||
'general-conditions': {
|
||||
title: 'الشروط العامة لاستخدام الموقع',
|
||||
paragraphs: [
|
||||
'أنت الآن تطّلع على الشروط العامة لاستخدام موقع RentalDriveGo. للاطلاع على الشروط العامة لاستخدام التطبيق، يرجى زيارة: https://www.rentaldrivego.ma/app-tc-ar',
|
||||
'تحدد هذه الشروط العامة القواعد المطبقة عند زيارتك أو دخولك أو تصفحك أو تواصلك أو تفاعلك بأي شكل آخر مع الموقع www.rentaldrivego.ma.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. الغرض',
|
||||
paragraphs: [
|
||||
'تنظم هذه الشروط استخدام الموقع www.rentaldrivego.ma الذي تديره شركة RentalDriveGo، وهي شركة ذات مسؤولية محدودة مسجلة في السجل التجاري بأزرو تحت رقم 00000، ويقع مقرها الاجتماعي في أزرو.',
|
||||
'كما تحدد هذه الشروط القواعد المتعلقة بمحتوى الموقع، وتطبق على كل شخص يزور الموقع أو يتواصل معنا أو مع مستخدمين آخرين من خلاله أو يضع رابطاً إليه أو يتفاعل معه بأي طريقة كانت.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. ما يتيحه الموقع',
|
||||
paragraphs: [
|
||||
'نقوم بتشغيل الموقع حصرياً لأغراض الإعلام والاستكشاف ودراسة السوق.',
|
||||
'قد يتم عرض معلومات تتعلق ببرنامج الولاء الخاص بـ RentalDriveGo للزوار، كما قد تُتاح لهم روابط للتنزيل عبر الموقع.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. الاتصال',
|
||||
paragraphs: [
|
||||
'يمكنك التواصل معنا عبر الموقع أو عبر البريد الإلكتروني على العنوان service-client@rentaldrivego.ma.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. قبول الشروط العامة للاستخدام',
|
||||
paragraphs: [
|
||||
'باستخدامك للموقع، فإنك توافق على هذه الشروط العامة للاستخدام وتتعهد بالالتزام بها. وإذا كنت لا تقبلها، فيجب عليك عدم استخدام الموقع.',
|
||||
'كما تنطبق سياسة الخصوصية وسياسة ملفات تعريف الارتباط الخاصة بنا على استخدامك للموقع.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. تعديل الشروط العامة للاستخدام',
|
||||
paragraphs: [
|
||||
'يجوز لنا تعديل هذه الشروط العامة للاستخدام في أي وقت. ولذلك ننصحك بالاطلاع على النسخة السارية كلما رغبت في استخدام الموقع.',
|
||||
'كما نحتفظ بالحق في تعديل أو تصحيح أو تحديث أي معلومات منشورة على الموقع في أي وقت.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. تعديل محتوى الموقع وتوفّره',
|
||||
paragraphs: [
|
||||
'يجوز لنا تحديث أو تعديل محتوى الموقع في أي وقت بما يعكس تطور احتياجات المستخدمين والشركاء.',
|
||||
'يتم توفير الموقع مجاناً. وعلى الرغم من أننا نبذل وسائل معقولة لمنع الانقطاعات وحلها، فإننا لا نضمن التوفر المستمر أو غير المنقطع للموقع.',
|
||||
'يجوز لنا تعليق أو سحب أو تقييد إتاحة كل الموقع أو جزء منه لأسباب تجارية أو تشغيلية.',
|
||||
'أنت مسؤول عن التأكد من أن جميع الأشخاص الذين يدخلون إلى الموقع عبر اتصالك بالإنترنت على علم بهذه الشروط ويلتزمون بها.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. نطاق المعلومات الواردة في الموقع',
|
||||
paragraphs: [
|
||||
'يتم تقديم محتوى الموقع لأغراض إعلامية عامة فقط، ولا يشكل خدمة توصية أو مشورة يمكن الاعتماد عليها.',
|
||||
'ورغم أننا نبذل جهوداً معقولة لتحديث المعلومات المنشورة على الموقع، فإننا لا نضمن، صراحةً أو ضمناً، أن يكون المحتوى دقيقاً أو كاملاً أو محدثاً.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '8. المسؤولية تجاه المواقع التابعة للغير',
|
||||
paragraphs: [
|
||||
'لا نتحمل المسؤولية عن المواقع الإلكترونية التابعة للغير التي قد يتضمن الموقع روابط إليها.',
|
||||
'أي روابط نحو محتوى أو مواقع أو موارد تابعة للغير يتم توفيرها لأغراض إعلامية فقط، ولا يمكن تحميلنا المسؤولية عن محتواها أو دقتها أو جودتها.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '9. تحديد المسؤولية',
|
||||
paragraphs: [
|
||||
'أنت توافق على أن الدخول إلى الموقع واستخدامه، وكذلك الصفحات المرتبطة به، يكون على مسؤوليتك الخاصة.',
|
||||
'لا تستبعد هذه الشروط ولا تحد من مسؤوليتنا في الحالات التي يكون فيها هذا الاستبعاد أو التحديد غير قانوني.',
|
||||
'ولا نحن ولا شركاؤنا أو الجهات المرتبطة بنا نتحمل أي مسؤولية عن الأضرار الناتجة عن استخدام المعلومات أو الملفات أو الروابط أو المواقع التي يمكن الوصول إليها من خلال الموقع أو استلامها عبره.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '10. مسؤولياتك',
|
||||
paragraphs: [
|
||||
'أنت تقر بأننا قد نستخدم المعلومات التي تقدمها لنا عندما تكون قد منحت الموافقة المطلوبة أو عندما يوجد أساس قانوني آخر لذلك.',
|
||||
'وتتعهد باستخدام الموقع فقط لأغراض مشروعة.',
|
||||
'وإذا رأينا أن هناك خرقاً لهذه الشروط العامة للاستخدام، فيجوز لنا اتخاذ أي إجراء نراه مناسباً.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '11. الملكية الفكرية',
|
||||
paragraphs: [
|
||||
'الموقع ملك لنا، والموقع بكامله محمي بموجب القوانين المتعلقة بالملكية الفكرية.',
|
||||
'وجميع حقوق النسخ والتمثيل محفوظة.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function isFooterPageSlug(value: string): value is FooterPageSlug {
|
||||
return footerPageSlugs.includes(value as FooterPageSlug)
|
||||
}
|
||||
|
||||
export function getFooterPageContent(language: CarplaceLanguage, slug: FooterPageSlug): FooterPageContent {
|
||||
return footerContent[language][slug]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const cookieValues = vi.hoisted(() => new Map<string, string>())
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieValues.get(name)
|
||||
return value === undefined ? undefined : { value }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { getCarplaceLanguage } from './i18n.server'
|
||||
import { SHARED_LANGUAGE_COOKIE } from './i18n'
|
||||
|
||||
describe('getCarplaceLanguage', () => {
|
||||
beforeEach(() => cookieValues.clear())
|
||||
|
||||
it.each(['en', 'fr', 'ar'] as const)('reads the shared %s language cookie', async (language) => {
|
||||
cookieValues.set(SHARED_LANGUAGE_COOKIE, language)
|
||||
await expect(getCarplaceLanguage()).resolves.toBe(language)
|
||||
})
|
||||
|
||||
it('defaults to English when the shared cookie is absent or invalid', async () => {
|
||||
await expect(getCarplaceLanguage()).resolves.toBe('en')
|
||||
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
|
||||
await expect(getCarplaceLanguage()).resolves.toBe('en')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { SHARED_LANGUAGE_COOKIE, isCarplaceLanguage, type CarplaceLanguage } from './i18n'
|
||||
|
||||
export async function getCarplaceLanguage(): Promise<CarplaceLanguage> {
|
||||
const cookieValue = (await cookies()).get(SHARED_LANGUAGE_COOKIE)?.value
|
||||
return isCarplaceLanguage(cookieValue) ? cookieValue : 'en'
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isCarplaceLanguage } from './i18n'
|
||||
|
||||
describe('carplace language guard', () => {
|
||||
it('accepts the supported carplace locales', () => {
|
||||
expect(isCarplaceLanguage('en')).toBe(true)
|
||||
expect(isCarplaceLanguage('fr')).toBe(true)
|
||||
expect(isCarplaceLanguage('ar')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unsupported, missing, and case-mismatched locales', () => {
|
||||
expect(isCarplaceLanguage('de')).toBe(false)
|
||||
expect(isCarplaceLanguage('EN')).toBe(false)
|
||||
expect(isCarplaceLanguage('')).toBe(false)
|
||||
expect(isCarplaceLanguage(null)).toBe(false)
|
||||
expect(isCarplaceLanguage(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export type CarplaceLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
|
||||
export function isCarplaceLanguage(value: string | null | undefined): value is CarplaceLanguage {
|
||||
return value === 'en' || value === 'fr' || value === 'ar'
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getScopedPreferenceCookieName, getScopedPreferenceKey, readScopedPreference, writeScopedPreference } from './preferences'
|
||||
|
||||
function installBrowser(cookie = '') {
|
||||
const store = new Map<string, string>()
|
||||
let cookieValue = cookie
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { localStorage: { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => store.set(key, value) } },
|
||||
})
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get cookie() { return cookieValue },
|
||||
set cookie(value: string) {
|
||||
const [pair] = value.split(';')
|
||||
const [name, val] = pair.split('=')
|
||||
cookieValue = cookieValue.split(';').map((chunk) => chunk.trim()).filter(Boolean).filter((chunk) => !chunk.startsWith(`${name}=`)).concat(`${name}=${val}`).join('; ')
|
||||
},
|
||||
},
|
||||
})
|
||||
return { store, get cookie() { return cookieValue } }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
})
|
||||
|
||||
describe('Carplace preferences', () => {
|
||||
it('uses the shared preference keys', () => {
|
||||
installBrowser()
|
||||
expect(getScopedPreferenceKey('rentaldrivego-language')).toBe('rentaldrivego-language')
|
||||
expect(getScopedPreferenceCookieName('rentaldrivego-language')).toBe('rentaldrivego-language')
|
||||
})
|
||||
|
||||
it('prefers the shared cookie over local storage', () => {
|
||||
const browser = installBrowser('rentaldrivego-language=fr')
|
||||
browser.store.set('rentaldrivego-language', 'ar')
|
||||
expect(readScopedPreference('rentaldrivego-language')).toBe('fr')
|
||||
})
|
||||
|
||||
it('writes one shared value to storage and cookies', () => {
|
||||
const browser = installBrowser()
|
||||
writeScopedPreference('rentaldrivego-theme', 'dark')
|
||||
expect(browser.store.get('rentaldrivego-theme')).toBe('dark')
|
||||
expect(browser.cookie).toContain('rentaldrivego-theme=dark')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
|
||||
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
|
||||
function readEmployeeToken() {
|
||||
return null
|
||||
}
|
||||
|
||||
function decodeEmployeeId(token: string | null) {
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
const encoded = token.split('.')[1] ?? ''
|
||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const payload = JSON.parse(atob(padded)) as { sub?: string }
|
||||
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getScopedPreferenceKey(baseKey: string) {
|
||||
const employeeId = decodeEmployeeId(readEmployeeToken())
|
||||
return employeeId ? `${baseKey}:${employeeId}` : baseKey
|
||||
}
|
||||
|
||||
export function getScopedPreferenceCookieName(baseKey: string) {
|
||||
const employeeId = decodeEmployeeId(readEmployeeToken())
|
||||
return employeeId ? `${baseKey}--${employeeId}` : baseKey
|
||||
}
|
||||
|
||||
function readCookie(name: string) {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.map((chunk) => chunk.trim())
|
||||
.find((chunk) => chunk.startsWith(`${name}=`))
|
||||
?.slice(name.length + 1) ?? null
|
||||
}
|
||||
|
||||
function writeCookie(name: string, value: string) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
|
||||
export function readCurrentUserScopedPreference(baseKey: string) {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
|
||||
if (scopedCookie) return scopedCookie
|
||||
|
||||
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
|
||||
}
|
||||
|
||||
export function readScopedPreference(baseKey: string) {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
|
||||
if (scopedCookie) return scopedCookie
|
||||
|
||||
const sharedCookie = readCookie(baseKey)
|
||||
if (sharedCookie) return sharedCookie
|
||||
|
||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
||||
const candidates = [scopedKey, baseKey]
|
||||
|
||||
for (const key of candidates) {
|
||||
const value = window.localStorage.getItem(key)
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function writeScopedPreference(baseKey: string, value: string) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
||||
const scopedCookie = getScopedPreferenceCookieName(baseKey)
|
||||
|
||||
writeCookie(baseKey, value)
|
||||
if (scopedCookie !== baseKey) {
|
||||
writeCookie(scopedCookie, value)
|
||||
}
|
||||
|
||||
window.localStorage.setItem(scopedKey, value)
|
||||
window.localStorage.setItem(baseKey, value)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installBrowser(_token?: string) {
|
||||
const store = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => store.delete(key)),
|
||||
},
|
||||
},
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('renter client helpers', () => {
|
||||
it('clears renter sessions through the API logout endpoint', async () => {
|
||||
installBrowser('renter-token')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { success: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
const { clearRenterSession } = await import('./renter')
|
||||
|
||||
clearRenterSession()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/auth\/renter\/logout$/), expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}))
|
||||
})
|
||||
|
||||
it('sends cookies and unwraps renter profile data', async () => {
|
||||
installBrowser('renter-token')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: { id: 'renter_1' } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
const { loadRenterProfile } = await import('./renter')
|
||||
|
||||
await expect(loadRenterProfile()).resolves.toEqual({ id: 'renter_1' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/auth\/renter\/me$/), expect.objectContaining({
|
||||
credentials: 'include',
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('removes stale tokens and throws renter auth errors on 401', async () => {
|
||||
installBrowser('expired-token')
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ ok: false, status: 401, json: async () => ({ message: 'Unauthorized' }) })),
|
||||
})
|
||||
const { loadRenterNotifications, RenterAuthError } = await import('./renter')
|
||||
|
||||
await expect(loadRenterNotifications()).rejects.toBeInstanceOf(RenterAuthError)
|
||||
|
||||
})
|
||||
|
||||
it('serializes preference updates to the renter notification endpoint', async () => {
|
||||
installBrowser('renter-token')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { success: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
const { updateRenterPreferences } = await import('./renter')
|
||||
const body = [{ notificationType: 'reservation', channel: 'email', enabled: true }]
|
||||
|
||||
await expect(updateRenterPreferences(body)).resolves.toEqual({ success: true })
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/notifications\/renter\/preferences$/), expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export interface SavedCompany {
|
||||
id: string
|
||||
brand?: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface RenterProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
preferredLocale?: string
|
||||
preferredCurrency?: string
|
||||
emailVerified?: boolean
|
||||
savedCompanies?: SavedCompany[]
|
||||
}
|
||||
|
||||
export interface RenterPreference {
|
||||
notificationType: string
|
||||
channel: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RenterNotification {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
type: string
|
||||
status: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export class RenterAuthError extends Error {
|
||||
constructor(message = 'Authentication required') {
|
||||
super(message)
|
||||
this.name = 'RenterAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
async function renterFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
const json = await res.json().catch(() => null)
|
||||
if (res.status === 401) {
|
||||
throw new RenterAuthError()
|
||||
}
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
export function clearRenterSession() {
|
||||
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
|
||||
}
|
||||
|
||||
export function loadRenterProfile() {
|
||||
return renterFetch<RenterProfile>('/auth/renter/me')
|
||||
}
|
||||
|
||||
export function updateRenterProfile(body: Partial<RenterProfile>) {
|
||||
return renterFetch<RenterProfile>('/auth/renter/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function loadRenterNotifications() {
|
||||
return renterFetch<RenterNotification[]>('/notifications/renter')
|
||||
}
|
||||
|
||||
export function markRenterNotificationRead(id: string) {
|
||||
return renterFetch<{ success: boolean }>(`/notifications/renter/${id}/read`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function markAllRenterNotificationsRead() {
|
||||
return renterFetch<{ success: boolean }>('/notifications/renter/read-all', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function loadRenterPreferences() {
|
||||
return renterFetch<RenterPreference[]>('/notifications/renter/preferences')
|
||||
}
|
||||
|
||||
export function updateRenterPreferences(body: RenterPreference[]) {
|
||||
return renterFetch<{ success: boolean }>('/notifications/renter/preferences', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const nextServer = vi.hoisted(() => {
|
||||
const next = vi.fn((init?: { request?: { headers?: Headers } }) => ({
|
||||
kind: 'next',
|
||||
init,
|
||||
cookies: { set: vi.fn() },
|
||||
}))
|
||||
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
|
||||
kind: 'response', body, status: init?.status,
|
||||
})) as any
|
||||
MockNextResponse.next = next
|
||||
return { next, MockNextResponse }
|
||||
})
|
||||
|
||||
vi.mock('next/server', () => ({ NextResponse: nextServer.MockNextResponse }))
|
||||
|
||||
function request(options: { cookies?: Record<string, string>; headers?: Record<string, string> } = {}) {
|
||||
const cookies = new Map(Object.entries(options.cookies ?? {}))
|
||||
return {
|
||||
cookies: { get: vi.fn((name: string) => cookies.has(name) ? { value: cookies.get(name) } : undefined) },
|
||||
headers: new Headers(options.headers ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => nextServer.next.mockClear())
|
||||
|
||||
describe('Carplace language bootstrap', () => {
|
||||
it('rejects forged middleware subrequest headers', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
const response = proxy(request({ headers: { 'x-middleware-subrequest': 'middleware:middleware' } }) as never) as any
|
||||
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
|
||||
expect(nextServer.next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a valid shared language cookie unchanged', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
const response = proxy(request({ cookies: { 'rentaldrivego-language': 'fr' } }) as never) as any
|
||||
expect(response.kind).toBe('next')
|
||||
expect(response.cookies.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ar-MA,fr;q=0.8', 'ar'],
|
||||
['de-DE,fr-FR;q=0.7', 'fr'],
|
||||
['de-DE,es;q=0.9', 'en'],
|
||||
])('resolves %s as %s', async (acceptLanguage, expected) => {
|
||||
const { proxy } = await import('./proxy')
|
||||
const response = proxy(request({ headers: { 'accept-language': acceptLanguage } }) as never) as any
|
||||
expect(response.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', expected, expect.objectContaining({ path: '/', sameSite: 'lax' }))
|
||||
expect(response.init?.request?.headers?.get('cookie')).toBe(`rentaldrivego-language=${expected}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
|
||||
type Language = 'en' | 'fr' | 'ar'
|
||||
|
||||
function isValidLanguage(value: string | null | undefined): value is Language {
|
||||
return value === 'en' || value === 'fr' || value === 'ar'
|
||||
}
|
||||
|
||||
function detectFromAcceptLanguage(header: string | null): Language {
|
||||
if (!header) return 'en'
|
||||
for (const entry of header.split(',')) {
|
||||
const code = entry.split(';')[0].trim().split('-')[0].toLowerCase()
|
||||
if (isValidLanguage(code)) return code
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
if (request.headers.has('x-middleware-subrequest')) {
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
const currentLanguage = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
|
||||
if (isValidLanguage(currentLanguage)) return NextResponse.next()
|
||||
|
||||
const language = detectFromAcceptLanguage(request.headers.get('accept-language'))
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
const existingCookies = request.headers.get('cookie') ?? ''
|
||||
const languageCookie = `${SHARED_LANGUAGE_COOKIE}=${language}`
|
||||
requestHeaders.set('cookie', existingCookies ? `${existingCookies}; ${languageCookie}` : languageCookie)
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
||||
response.cookies.set(SHARED_LANGUAGE_COOKIE, language, {
|
||||
maxAge: 365 * 24 * 60 * 60,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|_next/webpack-hmr|favicon\\.ico|.*\\.(?:png|ico|jpg|jpeg|svg|webp)$).*)'],
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user