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

This commit is contained in:
root
2026-07-02 18:15:42 -04:00
parent ad5f5ebab7
commit 7ff2dbb139
214 changed files with 5258 additions and 5906 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http:
const apiUrl = new URL(apiOrigin)
const ADMIN_BASE_PATH = '/admin'
// In Docker dev the admin app runs on port 3002 while the storefront proxy
// In Docker dev the admin app runs on port 3002 while the Carplace proxy
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
@@ -71,7 +71,7 @@ interface CompanyDetail {
whatsappNumber: string | null
defaultLocale: string
defaultCurrency: string
isListedOnMarketplace: boolean
isListedOnCarplace: boolean
} | null
contractSettings: {
legalName: string | null
@@ -171,7 +171,7 @@ interface FormState {
whatsappNumber: string
defaultLocale: string
defaultCurrency: string
isListedOnMarketplace: boolean
isListedOnCarplace: boolean
}
contractSettings: {
legalName: string
@@ -277,7 +277,7 @@ function createFormState(company: CompanyDetail): FormState {
whatsappNumber: company.brand?.whatsappNumber ?? '',
defaultLocale: company.brand?.defaultLocale ?? 'en',
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true,
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
},
contractSettings: {
legalName: company.contractSettings?.legalName ?? '',
@@ -420,7 +420,7 @@ export default function AdminCompanyDetailPage() {
whatsappNumber: toNullable(form.brand.whatsappNumber),
defaultLocale: form.brand.defaultLocale,
defaultCurrency: form.brand.defaultCurrency,
isListedOnMarketplace: form.brand.isListedOnMarketplace,
isListedOnCarplace: form.brand.isListedOnCarplace,
},
contractSettings: {
legalName: toNullable(form.contractSettings.legalName),
@@ -723,8 +723,8 @@ export default function AdminCompanyDetailPage() {
<input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.isListedOnMarketplace} onChange={(e) => updateSection('brand', { isListedOnMarketplace: e.target.checked })} />
Listed on storefront
<input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
Listed on Carplace
</label>
</div>
</section>
+4 -4
View File
@@ -24,7 +24,7 @@ export default function AdminDashboardPage() {
brand: 'RentalDriveGo',
eyebrow: 'Operations command',
platformOverview: 'RentalDriveGo admin dashboard',
subtitle: 'Monitor storefront health, subscription coverage, renter activity, and operator actions from one control surface.',
subtitle: 'Monitor Carplace health, subscription coverage, renter activity, and operator actions from one control surface.',
liveSignal: 'Live platform signal',
readiness: 'Operational readiness',
readinessBody: 'Core admin workflows are connected and ready for platform support.',
@@ -36,14 +36,14 @@ export default function AdminDashboardPage() {
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
],
health: ['Tenant accounts', 'Storefront identity', 'Admin audit trail'],
health: ['Tenant accounts', 'Carplace identity', 'Admin audit trail'],
},
fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
brand: 'RentalDriveGo',
eyebrow: 'Centre des opérations',
platformOverview: 'Tableau de bord admin RentalDriveGo',
subtitle: 'Suivez la santé de la storefront, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
subtitle: 'Suivez la santé de la Carplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
liveSignal: 'Signal plateforme en direct',
readiness: 'Disponibilité opérationnelle',
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
@@ -55,7 +55,7 @@ export default function AdminDashboardPage() {
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
],
health: ['Comptes locataires', 'Identité storefront', 'Piste daudit admin'],
health: ['Comptes locataires', 'Identité Carplace', 'Piste daudit admin'],
},
ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
@@ -3,12 +3,12 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
cloneStorefrontHomepageContent,
resolveStorefrontHomepageSections,
type StorefrontHomepageConfig,
type StorefrontHomepageContent,
type StorefrontHomepageSectionType,
type StorefrontLanguage,
cloneCarplaceHomepageContent,
resolveCarplaceHomepageSections,
type CarplaceHomepageConfig,
type CarplaceHomepageContent,
type CarplaceHomepageSectionType,
type CarplaceLanguage,
} from '@rentaldrivego/types'
import { useAdminI18n } from '@/components/I18nProvider'
import { ADMIN_API_BASE } from '@/lib/api'
@@ -35,15 +35,15 @@ const STATUS_COLORS: Record<string, string> = {
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
function cloneHomepageContent(content: StorefrontHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as StorefrontHomepageConfig
;(['en', 'fr', 'ar'] as StorefrontLanguage[]).forEach((language) => {
cloned[language].sections = resolveStorefrontHomepageSections(cloned[language].sections)
function cloneHomepageContent(content: CarplaceHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as CarplaceHomepageConfig
;(['en', 'fr', 'ar'] as CarplaceLanguage[]).forEach((language) => {
cloned[language].sections = resolveCarplaceHomepageSections(cloned[language].sections)
})
return cloned
}
const HOMEPAGE_SECTIONS: StorefrontHomepageSectionType[] = [
const HOMEPAGE_SECTIONS: CarplaceHomepageSectionType[] = [
'hero',
'surface',
'pillars',
@@ -58,12 +58,12 @@ export default function AdminSiteConfigPage() {
const copy = {
en: {
title: 'Site configuration',
description: 'Edit the main storefront homepage here, or jump into a company to manage its branded public homepage and menu.',
description: 'Edit the main Carplace homepage here, or jump into a company to manage its branded public homepage and menu.',
homepageTitle: 'Main website homepage',
homepageDescription: 'This controls the storefront homepage shown on the main RentalDriveGo website.',
homepageDescription: 'This controls the Carplace homepage shown on the main RentalDriveGo website.',
saveHomepage: 'Save homepage',
savingHomepage: 'Saving…',
homepageSaved: 'Storefront homepage saved.',
homepageSaved: 'Carplace homepage saved.',
search: 'Search by company or slug…',
loading: 'Loading…',
empty: 'No companies found',
@@ -86,7 +86,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Live preview',
previewDescription: 'Draft changes appear here before you save them.',
homepageSections: 'Homepage sections',
homepageSectionsDescription: 'Add or remove blocks from the main storefront homepage.',
homepageSectionsDescription: 'Add or remove blocks from the main Carplace homepage.',
addSection: 'Add',
removeSection: 'Remove',
expand: 'Expand',
@@ -94,12 +94,12 @@ export default function AdminSiteConfigPage() {
},
fr: {
title: 'Configuration du site',
description: 'Modifiez ici la page daccueil principale de la storefront, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
description: 'Modifiez ici la page daccueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
homepageTitle: 'Page daccueil du site principal',
homepageDescription: 'Cette section contrôle la page daccueil storefront affichée sur le site principal de RentalDriveGo.',
homepageDescription: 'Cette section contrôle la page daccueil Carplace affichée sur le site principal de RentalDriveGo.',
saveHomepage: 'Enregistrer la page daccueil',
savingHomepage: 'Enregistrement…',
homepageSaved: 'Page daccueil storefront enregistrée.',
homepageSaved: 'Page daccueil Carplace enregistrée.',
search: 'Rechercher par entreprise ou slug…',
loading: 'Chargement…',
empty: 'Aucune entreprise trouvée',
@@ -112,7 +112,7 @@ export default function AdminSiteConfigPage() {
companiesTitle: 'Sites de marque des entreprises',
languageLabel: 'Langue',
hero: 'Bloc principal',
surface: 'Bloc storefront',
surface: 'Bloc Carplace',
companySection: 'Bloc opérateur',
renterSection: 'Bloc client',
valueSection: 'Blocs de valeur',
@@ -122,7 +122,7 @@ export default function AdminSiteConfigPage() {
previewTitle: 'Aperçu en direct',
previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.',
homepageSections: 'Sections de la page daccueil',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil storefront principale.',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page daccueil Carplace principale.',
addSection: 'Ajouter',
removeSection: 'Retirer',
expand: 'Ouvrir',
@@ -171,8 +171,8 @@ export default function AdminSiteConfigPage() {
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [homepage, setHomepage] = useState<StorefrontHomepageConfig>(cloneStorefrontHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<StorefrontLanguage>(language)
const [homepage, setHomepage] = useState<CarplaceHomepageConfig>(cloneCarplaceHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<CarplaceLanguage>(language)
const [homepageSaving, setHomepageSaving] = useState(false)
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
const [homepageExpanded, setHomepageExpanded] = useState(false)
@@ -189,7 +189,7 @@ export default function AdminSiteConfigPage() {
credentials: 'include',
cache: 'no-store',
}),
fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, {
fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
credentials: 'include',
cache: 'no-store',
}),
@@ -202,7 +202,7 @@ export default function AdminSiteConfigPage() {
const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) {
setHomepage(cloneHomepageContent(homepageJson.data as StorefrontHomepageConfig))
setHomepage(cloneHomepageContent(homepageJson.data as CarplaceHomepageConfig))
}
} catch (err: any) {
setError(err.message)
@@ -225,7 +225,7 @@ export default function AdminSiteConfigPage() {
)
}, [search, companies])
function updateHomepageContent(patch: Partial<StorefrontHomepageContent>) {
function updateHomepageContent(patch: Partial<CarplaceHomepageContent>) {
setHomepage((current) => ({
...current,
[homepageLanguage]: {
@@ -257,16 +257,16 @@ export default function AdminSiteConfigPage() {
}
function getActiveSections() {
return resolveStorefrontHomepageSections(activeContent.sections)
return resolveCarplaceHomepageSections(activeContent.sections)
}
function addHomepageSection(section: StorefrontHomepageSectionType) {
function addHomepageSection(section: CarplaceHomepageSectionType) {
const sections = getActiveSections()
if (sections.includes(section)) return
updateHomepageContent({ sections: [...sections, section] })
}
function removeHomepageSection(section: StorefrontHomepageSectionType) {
function removeHomepageSection(section: CarplaceHomepageSectionType) {
const sections = getActiveSections().filter((item) => item !== section)
if (sections.length === 0) return
updateHomepageContent({ sections })
@@ -276,7 +276,7 @@ export default function AdminSiteConfigPage() {
setHomepageSaving(true)
setHomepageMessage(null)
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, {
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/carplace-homepage`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
@@ -286,7 +286,7 @@ export default function AdminSiteConfigPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
setHomepage(cloneHomepageContent(json.data as StorefrontHomepageConfig))
setHomepage(cloneHomepageContent(json.data as CarplaceHomepageConfig))
setHomepageMessage(copy.homepageSaved)
} catch (err: any) {
setError(err.message)
@@ -298,7 +298,7 @@ export default function AdminSiteConfigPage() {
const activeContent = homepage[homepageLanguage]
const activeSections = getActiveSections()
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
const sectionLabels: Record<StorefrontHomepageSectionType, string> = {
const sectionLabels: Record<CarplaceHomepageSectionType, string> = {
hero: copy.hero,
surface: copy.surface,
pillars: copy.valueSection,
@@ -337,7 +337,7 @@ export default function AdminSiteConfigPage() {
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
{(['en', 'fr', 'ar'] as StorefrontLanguage[]).map((value) => (
{(['en', 'fr', 'ar'] as CarplaceLanguage[]).map((value) => (
<button
key={value}
type="button"
+6 -4
View File
@@ -29,7 +29,7 @@ import customersRouter from './modules/customers/customer.routes'
import vehiclesRouter from './modules/vehicles/vehicle.routes'
import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.routes'
import storefrontRouter from './modules/storefront/storefront.routes'
import carplaceRouter from './modules/carplace/carplace.routes'
import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
@@ -96,8 +96,10 @@ const routeDocs = [
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/storefront/cities`, description: 'Storefront cities' },
{ method: 'GET', path: `${v1}/storefront/search`, description: 'Storefront search' },
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
{ method: 'GET', path: `${v1}/carplace/search`, description: 'Carplace paginated vehicle search' },
{ method: 'POST', path: `${v1}/carplace/quotes`, description: 'Carplace availability and price estimate' },
{ method: 'POST', path: `${v1}/carplace/reservations`, description: 'Create Carplace reservation request' },
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
@@ -188,7 +190,7 @@ export function createApp() {
app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/storefront`, publicLimiter, storefrontRouter)
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
+68 -68
View File
@@ -1,5 +1,5 @@
{
"storefrontHomepage": {
"carplaceHomepage": {
"en": {
"sections": [
"hero",
@@ -13,13 +13,13 @@
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Storefront discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared storefront, and every booking ends on the companys own branded checkout.",
"heroTitle": "Carplace discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared Carplace, and every booking ends on the companys own branded checkout.",
"startTrial": "Start free trial",
"exploreVehicles": "Explore vehicles",
"surfaceLabel": "Storefront surface",
"surfaceLabel": "Carplace surface",
"surfaceTitle": "Designed for two audiences at once.",
"surfaceBody": "Operators need control, renters need confidence. The storefront should show both without feeling like a template.",
"surfaceBody": "Operators need control, renters need confidence. The Carplace should show both without feeling like a template.",
"liveLabel": "Live network",
"trustedFleets": "Trusted fleets",
"brandedFlows": "Branded booking flows",
@@ -29,11 +29,11 @@
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
"renterKicker": "Renter experience",
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
"renterBody": "The storefront works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
"renterBody": "The Carplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
"pillars": [
{
"title": "Unified publishing",
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into storefront discovery and branded booking pages."
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into Carplace discovery and branded booking pages."
},
{
"title": "Qualified discovery",
@@ -47,7 +47,7 @@
"metrics": [
{
"value": "01",
"label": "Shared storefront visibility"
"label": "Shared Carplace visibility"
},
{
"value": "02",
@@ -61,7 +61,7 @@
"featureLabel": "What the product covers",
"features": [
"Fleet management with multi-photo uploads",
"Storefront offers and redirect booking flow",
"Carplace offers and redirect booking flow",
"Branded public booking site per company",
"Customer CRM, analytics, and billing controls"
],
@@ -76,7 +76,7 @@
{
"number": "2",
"title": "Add Your Fleet",
"description": "Upload vehicle photos, set prices, and create offers visible on the storefront."
"description": "Upload vehicle photos, set prices, and create offers visible on the Carplace."
},
{
"number": "3",
@@ -94,7 +94,7 @@
{
"step": "2",
"title": "Publish vehicles and offers",
"body": "Upload photos once and control what appears on the storefront and branded site."
"body": "Upload photos once and control what appears on the Carplace and branded site."
},
{
"step": "3",
@@ -104,8 +104,8 @@
],
"stepLabel": "Step",
"readyKicker": "Ready to launch",
"readyTitle": "The storefront homepage should explain the product in seconds.",
"readyBody": "Use the storefront to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
"readyTitle": "The Carplace homepage should explain the product in seconds.",
"readyBody": "Use the Carplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
"viewPricing": "View pricing",
"createWorkspace": "Create workspace"
},
@@ -122,13 +122,13 @@
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Une vitrine storefront plus claire et plus percutante.",
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une storefront commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
"heroTitle": "Une vitrine Carplace plus claire et plus percutante.",
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une Carplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
"startTrial": "Commencer lessai gratuit",
"exploreVehicles": "Explorer les véhicules",
"surfaceLabel": "Vitrine storefront",
"surfaceLabel": "Vitrine Carplace",
"surfaceTitle": "Pensée pour deux audiences à la fois.",
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La storefront doit montrer les deux sans ressembler à un modèle générique.",
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La Carplace doit montrer les deux sans ressembler à un modèle générique.",
"liveLabel": "Réseau actif",
"trustedFleets": "Flottes fiables",
"brandedFlows": "Parcours de marque",
@@ -138,11 +138,11 @@
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
"renterKicker": "Expérience client",
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.",
"renterBody": "La storefront sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"renterBody": "La Carplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"pillars": [
{
"title": "Publication unifiée",
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte storefront et les pages de réservation de marque."
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte Carplace et les pages de réservation de marque."
},
{
"title": "Découverte qualifiée",
@@ -156,7 +156,7 @@
"metrics": [
{
"value": "01",
"label": "Visibilité storefront partagée"
"label": "Visibilité Carplace partagée"
},
{
"value": "02",
@@ -170,30 +170,30 @@
"featureLabel": "Ce que couvre le produit",
"features": [
"Gestion de flotte avec téléversement de plusieurs photos",
"Offres storefront et redirection vers la réservation",
"Offres Carplace et redirection vers la réservation",
"Site public de réservation par entreprise",
"CRM client, analytics et contrôle de facturation"
],
"howitworksKicker": "MISE EN ROUTE",
"howitworksTitle": "Comment ça marche",
"howitworksSteps": [
{
"number": "1",
"title": "Créer un compte",
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
},
{
"number": "2",
"title": "Ajouter votre flotte",
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la storefront."
},
{
"number": "3",
"title": "Commencer à vendre",
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
}
],
"stepsTitle": "Comment les entreprises démarrent",
"CRM client, analytics et contrôle de facturation"
],
"howitworksKicker": "MISE EN ROUTE",
"howitworksTitle": "Comment ça marche",
"howitworksSteps": [
{
"number": "1",
"title": "Créer un compte",
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
},
{
"number": "2",
"title": "Ajouter votre flotte",
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
},
{
"number": "3",
"title": "Commencer à vendre",
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
}
],
"stepsTitle": "Comment les entreprises démarrent",
"steps": [
{
"step": "1",
@@ -203,7 +203,7 @@
{
"step": "2",
"title": "Publiez véhicules et offres",
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la storefront et le site de marque."
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la Carplace et le site de marque."
},
{
"step": "3",
@@ -213,8 +213,8 @@
],
"stepLabel": "Étape",
"readyKicker": "Prêt à démarrer",
"readyTitle": "La page daccueil storefront doit présenter le produit en quelques secondes.",
"readyBody": "Utilisez la storefront pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
"readyTitle": "La page daccueil Carplace doit présenter le produit en quelques secondes.",
"readyBody": "Utilisez la Carplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
"viewPricing": "Voir les tarifs",
"createWorkspace": "Créer lespace"
},
@@ -281,28 +281,28 @@
"إدارة الأسطول مع رفع عدة صور",
"عروض السوق والتحويل إلى مسار الحجز",
"موقع حجز عام مخصص لكل شركة",
"إدارة العملاء والتحليلات والفوترة"
],
"howitworksKicker": "البدء",
"howitworksTitle": "كيف يعمل",
"howitworksSteps": [
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
},
{
"number": "2",
"title": "أضف أسطولك",
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
},
{
"number": "3",
"title": "ابدأ البيع",
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
}
],
"stepsTitle": "كيف تبدأ الشركات",
"إدارة العملاء والتحليلات والفوترة"
],
"howitworksKicker": "البدء",
"howitworksTitle": "كيف يعمل",
"howitworksSteps": [
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
},
{
"number": "2",
"title": "أضف أسطولك",
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
},
{
"number": "3",
"title": "ابدأ البيع",
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
}
],
"stepsTitle": "كيف تبدأ الشركات",
"steps": [
{
"step": "1",
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatDate,
storefrontReservationEmail,
carplaceReservationEmail,
resetPasswordEmail,
signupEmail,
} from './emailTranslations'
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
expect(html).toContain('45')
})
it('includes optional contact phone in storefront reservation HTML when present', () => {
const html = storefrontReservationEmail.html({
it('includes optional contact phone in Carplace reservation HTML when present', () => {
const html = carplaceReservationEmail.html({
firstName: 'Yassine',
vehicleYear: 2024,
vehicleMake: 'Dacia',
+2 -2
View File
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
}, lang),
}
// ─── Storefront reservation request ─────────────────────────────────────────
// ─── Carplace reservation request ─────────────────────────────────────────
export const storefrontReservationEmail = {
export const carplaceReservationEmail = {
subject: (vehicleName: string, lang: Lang) => t({
en: `Reservation Request Received — ${vehicleName}`,
fr: `Demande de réservation reçue — ${vehicleName}`,
+1 -1
View File
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Limiter for public storefront and site endpoints (no auth)
// Limiter for public carplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
+4 -4
View File
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
// ─── Site config ───────────────────────────────────────────────
router.get('/site-config/storefront-homepage', requireAdminAuth, async (req, res, next) => {
router.get('/site-config/carplace-homepage', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.getStorefrontHomepage())
ok(res, await service.getCarplaceHomepage())
} catch (err) { next(err) }
})
router.patch('/site-config/storefront-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.patch('/site-config/carplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { homepage } = parseBody(homepageUpdateSchema, req)
ok(res, await service.updateStorefrontHomepage(homepage, req.admin.id, req.ip))
ok(res, await service.updateCarplaceHomepage(homepage, req.admin.id, req.ip))
} catch (err) { next(err) }
})
+28 -20
View File
@@ -1,5 +1,13 @@
import { z } from 'zod'
import type { StorefrontHomepageContent, StorefrontHomepageHowItWorksStep, StorefrontHomepageMetric, StorefrontHomepagePillar, StorefrontHomepageSectionType, StorefrontHomepageStep, StorefrontHomepageTestimonial } from '@rentaldrivego/types'
import type {
CarplaceHomepageContent,
CarplaceHomepageHowItWorksStep,
CarplaceHomepageMetric,
CarplaceHomepagePillar,
CarplaceHomepageSectionType,
CarplaceHomepageStep,
CarplaceHomepageTestimonial,
} from '@rentaldrivego/types'
export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
@@ -154,7 +162,7 @@ export const adminCompanyUpdateSchema = z.object({
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
defaultCurrency: z.literal('MAD').optional(),
isListedOnMarketplace: z.boolean().optional(),
isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.any().optional(),
menuConfig: z.any().optional(),
}).optional(),
@@ -175,35 +183,35 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(),
})
const storefrontMetricSchema: z.ZodType<StorefrontHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const storefrontPillarSchema: z.ZodType<StorefrontHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const storefrontStepSchema: z.ZodType<StorefrontHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const storefrontHowItWorksStepSchema: z.ZodType<StorefrontHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const storefrontTestimonialSchema: z.ZodType<StorefrontHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const storefrontSectionSchema: z.ZodType<StorefrontHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const carplaceMetricSchema: z.ZodType<CarplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const carplacePillarSchema: z.ZodType<CarplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const carplaceStepSchema: z.ZodType<CarplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const carplaceHowItWorksStepSchema: z.ZodType<CarplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const carplaceTestimonialSchema: z.ZodType<CarplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const carplaceSectionSchema: z.ZodType<CarplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const storefrontHomepageContentSchema: z.ZodType<StorefrontHomepageContent> = z.object({
sections: z.array(storefrontSectionSchema).min(1),
const carplaceHomepageContentSchema: z.ZodType<CarplaceHomepageContent> = z.object({
sections: z.array(carplaceSectionSchema).min(1),
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
pillars: z.array(storefrontPillarSchema).length(3),
metrics: z.array(storefrontMetricSchema).length(3),
pillars: z.array(carplacePillarSchema).length(3),
metrics: z.array(carplaceMetricSchema).length(3),
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(storefrontHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(storefrontStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(storefrontTestimonialSchema).min(1),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(carplaceHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(carplaceStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(carplaceTestimonialSchema).min(1),
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
})
export const storefrontHomepageConfigSchema = z.object({
en: storefrontHomepageContentSchema,
fr: storefrontHomepageContentSchema,
ar: storefrontHomepageContentSchema,
export const carplaceHomepageConfigSchema = z.object({
en: carplaceHomepageContentSchema,
fr: carplaceHomepageContentSchema,
ar: carplaceHomepageContentSchema,
})
export const idParamSchema = z.object({
@@ -300,7 +308,7 @@ export const billingRefundSchema = z.object({
})
export const homepageUpdateSchema = z.object({
homepage: storefrontHomepageConfigSchema,
homepage: carplaceHomepageConfigSchema,
})
export const pricingUpdateSchema = z.object({
+6 -6
View File
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getStorefrontHomepageContent, saveStorefrontHomepageContent } from '../../services/platformContentService'
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
@@ -402,16 +402,16 @@ export function issueBillingRefund(
return billingService.issueRefund(invoiceId, data, adminId, ip)
}
export function getStorefrontHomepage() {
return getStorefrontHomepageContent()
export function getCarplaceHomepage() {
return getCarplaceHomepageContent()
}
export async function updateStorefrontHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveStorefrontHomepageContent(homepage)
export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveCarplaceHomepageContent(homepage)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'StorefrontHomepage',
resource: 'CarplaceHomepage',
after: toAuditJson(saved),
ipAddress: ip,
userAgent: undefined,
@@ -85,7 +85,7 @@ describe('analytics.service', () => {
},
] as never)
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
{ source: 'CARPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
] as never)
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
@@ -115,7 +115,7 @@ describe('analytics.service', () => {
totalAmount: 1200,
})])
expect(result.sourceBreakdown).toEqual([
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
{ source: 'CARPLACE', count: 3, revenue: 3600 },
{ source: 'DIRECT', count: 2, revenue: 0 },
])
expect(result.subscription).toEqual({
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { presentVehicleWithAvailability } from './storefront.presenter'
import { presentVehicleWithAvailability } from './carplace.presenter'
describe('storefront.presenter', () => {
describe('carplace.presenter', () => {
it('adds public availability fields without mutating the original vehicle shape', () => {
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './storefront.repo'
import * as repo from './carplace.repo'
describe('storefront.repo public query and write boundaries', () => {
describe('carplace.repo public query and write boundaries', () => {
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
@@ -33,20 +33,20 @@ describe('storefront.repo public query and write boundaries', () => {
vi.useRealTimers()
})
it('lists storefront cities only from active listed companies with a public city', async () => {
it('lists carplace cities only from active listed companies with a public city', async () => {
await repo.findCitiesFromCompanies()
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
where: {
status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
brand: { isListedOnCarplace: true, publicCity: { not: null } },
},
select: { brand: { select: { publicCity: true } } },
})
})
it('requires published vehicles and an active company when loading storefront vehicle details', async () => {
await repo.findVehicleForStorefront('vehicle_1', 'atlas')
it('requires published vehicles and an active company when loading carplace vehicle details', async () => {
await repo.findVehicleForCarplace('vehicle_1', 'atlas')
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
where: {
@@ -58,13 +58,13 @@ describe('storefront.repo public query and write boundaries', () => {
})
})
it('patches storefront customer address fields without deleting existing address metadata', async () => {
it('patches carplace customer address fields without deleting existing address metadata', async () => {
prismaMock.customer.findUnique.mockResolvedValueOnce({
id: 'customer_1',
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
})
await repo.upsertMarketplaceCustomer('company_1', {
await repo.upsertCarplaceCustomer('company_1', {
email: 'renter@example.test',
firstName: 'Nora',
lastName: 'Renter',
@@ -86,11 +86,11 @@ describe('storefront.repo public query and write boundaries', () => {
})
})
it('creates storefront reservations as draft storefront-sourced records', async () => {
it('creates carplace reservations as draft carplace-sourced records', async () => {
const startDate = new Date('2026-07-01T10:00:00.000Z')
const endDate = new Date('2026-07-05T10:00:00.000Z')
await repo.createStorefrontReservation({
await repo.createCarplaceReservation({
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
@@ -108,7 +108,7 @@ describe('storefront.repo public query and write boundaries', () => {
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
source: 'MARKETPLACE',
source: 'CARPLACE',
status: 'DRAFT',
}),
})
@@ -13,7 +13,7 @@ export async function findCitiesFromCompanies() {
return prisma.company.findMany({
where: {
status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
brand: { isListedOnCarplace: true, publicCity: { not: null } },
},
select: { brand: { select: { publicCity: true } } },
})
@@ -23,7 +23,7 @@ export async function findListedCompanies(where: any, skip: number, take: number
return prisma.company.findMany({
where,
include: {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, carplaceRating: true, primaryColor: true } },
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
},
skip,
@@ -35,31 +35,31 @@ export async function findPublishedVehicles(where: any) {
return prisma.vehicle.findMany({
where,
include: {
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, carplaceRating: true, primaryColor: true } } } },
},
orderBy: { dailyRate: 'asc' },
})
}
export async function findVehicleForStorefront(vehicleId: string, companySlug: string) {
export async function findVehicleForCarplace(vehicleId: string, companySlug: string) {
return prisma.vehicle.findFirst({
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
where: { id: vehicleId, isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: companySlug }, { brand: { subdomain: companySlug } }] } },
include: { company: { include: { brand: true } } },
})
}
export async function findVehicleForStorefrontById(vehicleId: string) {
export async function findVehicleForCarplaceById(vehicleId: string) {
return prisma.vehicle.findFirst({
where: {
id: vehicleId,
isPublished: true,
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
},
include: { company: { include: { brand: true } } },
})
}
export async function upsertMarketplaceCustomer(companyId: string, data: {
export async function upsertCarplaceCustomer(companyId: string, data: {
email: string
firstName: string
lastName: string
@@ -115,17 +115,17 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
return prisma.customer.update({ where: { id: existing.id }, data: payload })
}
export async function createStorefrontReservation(data: {
export async function createCarplaceReservation(data: {
companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
}) {
return prisma.reservation.create({
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
data: { ...data, source: 'CARPLACE', status: 'DRAFT' },
})
}
export async function createStorefrontFunnelEvent(data: {
export async function createCarplaceFunnelEvent(data: {
eventName: string
companySlug: string
vehicleId: string
@@ -138,12 +138,12 @@ export async function createStorefrontFunnelEvent(data: {
where: {
id: data.vehicleId,
isPublished: true,
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
company: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug: data.companySlug }, { brand: { subdomain: data.companySlug } }] },
},
select: { companyId: true },
})
return prisma.marketplaceFunnelEvent.create({
return prisma.carplaceFunnelEvent.create({
data: {
eventName: data.eventName,
companyId: vehicle?.companyId ?? null,
@@ -159,7 +159,7 @@ export async function createStorefrontFunnelEvent(data: {
export async function findCompanyPage(slug: string) {
return prisma.company.findFirst({
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] },
include: {
brand: true,
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
@@ -169,7 +169,7 @@ export async function findCompanyPage(slug: string) {
}
export async function findCompanyBySlug(slug: string) {
return prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
return prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
}
export async function findCompanyReviews(companyId: string) {
@@ -189,7 +189,7 @@ export async function findCompanyVehicles(companyId: string) {
}
export async function findVehicleById(slug: string, vehicleId: string) {
const company = await prisma.company.findFirstOrThrow({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const company = await prisma.company.findFirstOrThrow({ where: { status: { in: ['ACTIVE', 'TRIALING'] }, OR: [{ slug }, { brand: { subdomain: slug } }] } })
return prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
include: { company: { include: { brand: true } } },
@@ -0,0 +1,164 @@
import { createHash } from 'node:crypto'
import { Router } from 'express'
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseParams, parseQuery } from '../../http/validate'
import { created, ok } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './carplace.service'
import {
carplaceQuoteSchema,
companiesQuerySchema,
carplaceFunnelEventSchema,
offerCodeParamSchema,
reviewBodySchema,
reviewTokenSchema,
paginationSchema,
searchSchema,
slugParamSchema,
carplaceReservationSchema,
vehicleAvailabilityQuerySchema,
vehicleParamSchema,
} from './carplace.schemas'
const router = Router()
router.use(optionalRenterAuth)
const idempotencyCache = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
const IDEMPOTENCY_TTL_MS = 15 * 60 * 1000
function cleanupIdempotencyCache(now = Date.now()) {
for (const [key, value] of idempotencyCache) if (value.expiresAt <= now) idempotencyCache.delete(key)
}
router.get('/home', async (_req, res, next) => {
try {
const [cities, offers, companies, search] = await Promise.all([
service.getCities(),
service.getPublicOffers(),
service.getListedCompanies({ page: 1, pageSize: 8 }),
service.searchVehiclesPage({ page: 1, pageSize: 9 }),
])
ok(res, { cities, offers: offers.slice(0, 6), companies, vehicles: search.items })
} catch (error) {
if (isDatabaseUnavailableError(error)) return ok(res, { cities: [], offers: [], companies: [], vehicles: [] })
next(error)
}
})
router.get('/cities', async (_req, res, next) => {
try { ok(res, await service.getCities()) }
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
})
router.get('/offers', async (_req, res, next) => {
try { ok(res, await service.getPublicOffers()) }
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
})
router.get('/companies', async (req, res, next) => {
try {
const filters = parseQuery(companiesQuerySchema, req)
const pagination = parseQuery(paginationSchema, req)
ok(res, await service.getListedCompanies({ ...filters, ...pagination }))
} catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
})
router.get('/search', async (req, res, next) => {
try {
const filters = parseQuery(searchSchema, req)
const pagination = parseQuery(paginationSchema, req)
ok(res, await service.searchVehiclesPage({ ...filters, ...pagination }))
} catch (error) {
if (isDatabaseUnavailableError(error)) return ok(res, { items: [], pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } })
next(error)
}
})
router.post('/quotes', async (req, res, next) => {
try { ok(res, await service.createCarplaceQuote(parseBody(carplaceQuoteSchema, req))) }
catch (error) { next(error) }
})
router.post('/reservations', async (req, res, next) => {
try {
cleanupIdempotencyCache()
const body = parseBody(carplaceReservationSchema, req)
const key = body.idempotencyKey ?? req.header('Idempotency-Key')?.trim()
const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex')
if (key) {
const cached = idempotencyCache.get(key)
if (cached && cached.expiresAt > Date.now()) {
if (cached.fingerprint !== fingerprint) return res.status(409).json({ error: 'idempotency_conflict', message: 'This idempotency key was already used with a different request', statusCode: 409 })
return ok(res, cached.result)
}
}
const result = await service.createCarplaceReservation(body)
if (key) idempotencyCache.set(key, { expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, fingerprint, result })
created(res, result)
} catch (error) {
if (isDatabaseUnavailableError(error)) return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
next(error)
}
})
router.post('/events', async (req, res, next) => {
try { ok(res, await service.trackCarplaceFunnelEvent(parseBody(carplaceFunnelEventSchema, req), req.renterId)) }
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, { success: false }); next(error) }
})
router.get('/review/:token', async (req, res, next) => {
try {
const { token } = parseParams(reviewTokenSchema, req)
ok(res, await service.getReviewContext(token))
} catch (error) { next(error) }
})
router.post('/review/:token', async (req, res, next) => {
try {
const { token } = parseParams(reviewTokenSchema, req)
created(res, await service.submitReview(token, parseBody(reviewBodySchema, req)))
} catch (error) { next(error) }
})
router.post('/offers/:code/validate', async (req, res, next) => {
try {
const { code } = parseParams(offerCodeParamSchema, req)
ok(res, await service.validateOfferCode(code))
} catch (error) { next(error) }
})
router.get('/:slug', async (req, res, next) => {
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyPage(slug)) }
catch (error) { next(error) }
})
router.get('/:slug/reviews', async (req, res, next) => {
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyReviews(slug)) }
catch (error) { if (isDatabaseUnavailableError(error)) return ok(res, []); next(error) }
})
router.get('/:slug/vehicles', async (req, res, next) => {
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyVehicles(slug)) }
catch (error) { next(error) }
})
router.get('/:slug/offers', async (req, res, next) => {
try { const { slug } = parseParams(slugParamSchema, req); ok(res, await service.getCompanyOffers(slug)) }
catch (error) { next(error) }
})
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try { const { slug, id } = parseParams(vehicleParamSchema, req); ok(res, await service.getVehicleDetail(slug, id)) }
catch (error) { next(error) }
})
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
try {
const { slug, id } = parseParams(vehicleParamSchema, req)
const range = parseQuery(vehicleAvailabilityQuerySchema, req)
ok(res, await service.getCarplaceVehicleAvailability(slug, id, range))
} catch (error) { next(error) }
})
export default router
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { storefrontReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './storefront.schemas'
import { carplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './carplace.schemas'
describe('storefront.schemas', () => {
describe('carplace.schemas', () => {
it('coerces pagination and caps untrusted public page sizes', () => {
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
@@ -18,8 +18,8 @@ describe('storefront.schemas', () => {
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
})
it('defaults storefront reservation language while keeping date and email validation strict', () => {
const parsed = storefrontReservationSchema.parse({
it('defaults carplace reservation language while keeping date and email validation strict', () => {
const parsed = carplaceReservationSchema.parse({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
@@ -30,8 +30,8 @@ describe('storefront.schemas', () => {
})
expect(parsed.language).toBe('fr')
expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
expect(() => carplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => carplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
})
it('validates review ratings as bounded integers', () => {
@@ -29,7 +29,17 @@ export const vehicleAvailabilityQuerySchema = z.object({
endDate: z.string().datetime(),
})
export const storefrontReservationSchema = z.object({
export const carplaceQuoteSchema = z.object({
vehicleId: z.string().cuid(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
pickupLocation: z.string().trim().max(100).optional(),
returnLocation: z.string().trim().max(100).optional(),
promoCode: z.string().trim().max(100).optional(),
})
export const carplaceReservationSchema = z.object({
vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100).optional(),
firstName: z.string().min(1).max(100),
@@ -37,6 +47,7 @@ export const storefrontReservationSchema = z.object({
email: z.string().email(),
// Contact info — collected at reservation time
phone: z.string().min(1).max(30).optional(),
driverAge: z.coerce.number().int().min(18).max(100).optional(),
// Identity & license — optional at reservation, collected at pickup
dateOfBirth: z.string().datetime().optional(),
nationality: z.string().min(1).max(100).optional(),
@@ -52,11 +63,13 @@ export const storefrontReservationSchema = z.object({
endDate: z.string().datetime(),
pickupLocation: z.string().trim().max(100).optional(),
returnLocation: z.string().trim().max(100).optional(),
promoCode: z.string().trim().max(100).optional(),
notes: z.string().max(500).optional(),
language: z.enum(['en', 'fr', 'ar']).default('fr'),
idempotencyKey: z.string().uuid().optional(),
})
export const marketplaceFunnelEventSchema = z.object({
export const carplaceFunnelEventSchema = z.object({
eventName: z.enum([
'booking_form_viewed',
'trip_dates_selected',
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./storefront.repo', () => ({
vi.mock('./carplace.repo', () => ({
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -12,16 +12,16 @@ vi.mock('./storefront.repo', () => ({
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
vi.mock('../../lib/emailTranslations', () => ({
storefrontReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
carplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
}))
import * as repo from './storefront.repo'
import * as repo from './carplace.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './storefront.service'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './carplace.service'
beforeEach(() => vi.clearAllMocks())
describe('storefront.service public page edges', () => {
describe('carplace.service public page edges', () => {
it('enriches company page vehicles with availability without changing company metadata', async () => {
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
vi.mocked(repo.findCompanyPage).mockResolvedValue({
@@ -1,8 +1,8 @@
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { storefrontReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './storefront.repo'
import { carplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './carplace.repo'
function normalizeLocation(value?: string | null) {
const normalized = value?.trim()
@@ -21,6 +21,28 @@ function includesLocation(values: string[], target: string) {
return values.some((value) => value.toLocaleLowerCase() === key)
}
function calculateOfferDiscount(
offer: { type?: string; discountValue?: number; specialRate?: number | null; maxRedemptions?: number | null; redemptionCount?: number | null },
baseSubtotal: number,
dailyRate: number,
rentalDays: number,
) {
if (offer.maxRedemptions != null && (offer.redemptionCount ?? 0) >= offer.maxRedemptions) {
throw new AppError('Offer code has reached its redemption limit', 409, 'offer_exhausted')
}
const value = Number(offer.discountValue ?? 0)
let discount = 0
if (offer.type === 'PERCENTAGE') discount = Math.round(baseSubtotal * (value / 100))
else if (offer.type === 'FIXED_AMOUNT') discount = value
else if (offer.type === 'FREE_DAY') discount = dailyRate * value
else if (offer.type === 'SPECIAL_RATE') {
const specialRate = Number(offer.specialRate ?? value)
discount = Math.max(0, baseSubtotal - (specialRate * rentalDays))
}
return Math.min(baseSubtotal, Math.max(0, Math.round(discount)))
}
function matchesVehicleLocationRules(
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
@@ -65,7 +87,7 @@ export async function getListedCompanies(params: {
}) {
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }
const where: any = { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } }
if (params.city) where.brand = { ...where.brand, publicCity: { contains: params.city, mode: 'insensitive' } }
if (params.hasOffer === 'true') {
where.offers = { some: { isPublic: true, isActive: true, validUntil: { gte: new Date() } } }
@@ -79,53 +101,106 @@ export async function searchVehicles(params: {
maxPrice?: number; transmission?: string; make?: string; model?: string
page?: number; pageSize?: number
}) {
const page = params.page ?? 1
const result = await searchVehiclesPage(params)
return result.items
}
export async function searchVehiclesPage(params: {
city?: string; pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different'
startDate?: string; endDate?: string; category?: string
maxPrice?: number; transmission?: string; make?: string; model?: string
page?: number; pageSize?: number
}) {
const page = params.page ?? 1
const pageSize = params.pageSize ?? 20
const where: any = {
isPublished: true,
status: 'AVAILABLE',
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnCarplace: true } },
}
if (params.category) where.category = params.category
if (params.category) where.category = params.category
if (params.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
if (params.transmission) where.transmission = params.transmission
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
if (params.make) where.make = { contains: params.make, mode: 'insensitive' }
if (params.model) where.model = { contains: params.model, mode: 'insensitive' }
if (params.city) {
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
where.company = { ...where.company, brand: { isListedOnCarplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
}
let range: { startDate: Date; endDate: Date } | undefined
if (params.startDate || params.endDate) {
if (!params.startDate || !params.endDate) throw new AppError('Both start and end dates are required', 400, 'invalid_dates')
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
range = { startDate, endDate }
}
const vehicles = await repo.findPublishedVehicles(where) as any[]
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
const availabilityByVehicle = new Map<string, any>()
const availability = await Promise.all(
pagedVehicles.map(async (v: any) => {
const a = await getVehicleAvailabilitySummary(v.id, {
companyId: v.companyId,
range: params.startDate && params.endDate
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
: undefined,
})
return [v.id, a] as const
}),
)
const availMap = new Map<string, any>(availability)
let availableVehicles = locationFiltered
if (range) {
const availability = await Promise.all(
locationFiltered.map(async (vehicle: any) => {
const result = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range })
availabilityByVehicle.set(vehicle.id, result)
return result.available ? vehicle : null
}),
)
availableVehicles = availability.filter((vehicle): vehicle is any => Boolean(vehicle))
}
return pagedVehicles.map((v: any) => ({
...v,
availability: availMap.get(v.id)?.available ?? null,
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
nextAvailableAt: availMap.get(v.id)?.nextAvailableAt ?? null,
}))
const pageItems = availableVehicles.slice((page - 1) * pageSize, page * pageSize)
const items = pageItems.map((vehicle: any) => {
const availability = availabilityByVehicle.get(vehicle.id)
return {
...vehicle,
availability: availability?.available ?? true,
availabilityStatus: availability?.status ?? 'AVAILABLE',
nextAvailableAt: availability?.nextAvailableAt ?? null,
}
})
const countValues = (key: 'category' | 'transmission' | 'make') => {
const counts = new Map<string, number>()
for (const vehicle of availableVehicles) {
const value = typeof vehicle[key] === 'string' ? vehicle[key].trim() : ''
if (value) counts.set(value, (counts.get(value) ?? 0) + 1)
}
return Array.from(counts, ([value, count]) => ({ value, count })).sort((a, b) => a.value.localeCompare(b.value))
}
const prices = availableVehicles.map((vehicle) => Number(vehicle.dailyRate)).filter(Number.isFinite)
return {
items,
pagination: {
page,
pageSize,
totalItems: availableVehicles.length,
totalPages: Math.ceil(availableVehicles.length / pageSize),
},
facets: {
categories: countValues('category'),
transmissions: countValues('transmission'),
makes: countValues('make'),
price: {
min: prices.length ? Math.min(...prices) : null,
max: prices.length ? Math.max(...prices) : null,
},
},
}
}
export async function getStorefrontVehicleAvailability(
export async function getCarplaceVehicleAvailability(
slug: string,
vehicleId: string,
params: { startDate: string; endDate: string },
) {
const vehicle = await repo.findVehicleForStorefront(vehicleId, slug)
const vehicle = await repo.findVehicleForCarplace(vehicleId, slug)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const startDate = new Date(params.startDate)
@@ -148,20 +223,85 @@ export async function getStorefrontVehicleAvailability(
}
}
export async function createCarplaceQuote(body: {
vehicleId: string
startDate: string
endDate: string
pickupLocation?: string
returnLocation?: string
promoCode?: string
}) {
const startDate = new Date(body.startDate)
const endDate = new Date(body.endDate)
if (!Number.isFinite(startDate.getTime()) || !Number.isFinite(endDate.getTime()) || endDate <= startDate) {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation)
const returnLocation = normalizeLocation(body.returnLocation) ?? pickupLocation
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
throw new AppError('Selected pickup location is not available for this vehicle', 400, 'invalid_pickup_location')
}
if (pickupLocation && returnLocation && pickupLocation.toLocaleLowerCase() !== returnLocation.toLocaleLowerCase()) {
if (!vehicle.allowDifferentDropoff) throw new AppError('This vehicle must be returned to the same pickup location', 400, 'same_dropoff_required')
if (dropoffLocations.length > 0 && !includesLocation(dropoffLocations, returnLocation)) {
throw new AppError('Selected return location is not available for this vehicle', 400, 'invalid_return_location')
}
}
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
companyId: vehicle.companyId,
range: { startDate, endDate },
})
const rentalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const baseSubtotal = vehicle.dailyRate * rentalDays
let discountAmount = 0
const discounts: Array<{ label: string; amount: number }> = []
if (body.promoCode) {
const offer = await repo.findOfferByCode(body.promoCode.trim())
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, rentalDays)
discounts.push({ label: offer.title, amount: discountAmount })
}
return {
available: availability.available,
availabilityStatus: availability.status,
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
currency: 'MAD' as const,
rentalDays,
dailyRate: vehicle.dailyRate,
baseSubtotal,
discounts,
fees: [],
taxes: [],
estimatedTotal: Math.max(0, baseSubtotal - discountAmount),
securityDeposit: null,
finalPriceRequiresCompanyConfirmation: true as const,
}
}
function generateBookingReference() {
const year = new Date().getUTCFullYear()
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
return `BK-${year}-${random}`
}
export async function createStorefrontReservation(body: {
export async function createCarplaceReservation(body: {
vehicleId: string; companySlug?: string; firstName: string; lastName: string
email: string; phone?: string; dateOfBirth?: string; nationality?: string
email: string; phone?: string; driverAge?: number; dateOfBirth?: string; nationality?: string
identityDocumentNumber?: string; fullAddress?: string
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
startDate: string; endDate: string
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
pickupLocation?: string; returnLocation?: string; promoCode?: string; notes?: string; language?: string
}) {
const startDate = new Date(body.startDate)
const endDate = new Date(body.endDate)
@@ -170,7 +310,7 @@ export async function createStorefrontReservation(body: {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
const vehicle = await repo.findVehicleForStorefrontById(body.vehicleId)
const vehicle = await repo.findVehicleForCarplaceById(body.vehicleId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation)
@@ -198,7 +338,7 @@ export async function createStorefrontReservation(body: {
})
}
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
const customer = await repo.upsertCarplaceCustomer(vehicle.companyId, {
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
@@ -215,10 +355,19 @@ export async function createStorefrontReservation(body: {
internationalLicenseNumber: body.internationalLicenseNumber,
})
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const baseSubtotal = vehicle.dailyRate * totalDays
let discountAmount = 0
if (body.promoCode) {
const offer = await repo.findOfferByCode(body.promoCode.trim())
if (!offer || offer.companyId !== vehicle.companyId) throw new AppError('Offer code is invalid or expired', 400, 'offer_invalid')
discountAmount = calculateOfferDiscount(offer, baseSubtotal, vehicle.dailyRate, totalDays)
}
const totalAmount = Math.max(0, baseSubtotal - discountAmount)
const reservation = await repo.createStorefrontReservation({
const reservationNotes = [body.notes?.trim(), body.driverAge ? `Driver age: ${body.driverAge}` : null].filter(Boolean).join('\n').slice(0, 500) || undefined
const reservation = await repo.createCarplaceReservation({
companyId: vehicle.companyId,
vehicleId: body.vehicleId,
customerId: customer.id,
@@ -229,7 +378,7 @@ export async function createStorefrontReservation(body: {
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount,
notes: body.notes,
notes: reservationNotes,
bookingReference: generateBookingReference(),
})
@@ -245,14 +394,14 @@ export async function createStorefrontReservation(body: {
sendTransactionalEmail({
to: body.email,
subject: storefrontReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: storefrontReservationEmail.html(emailOpts, lang),
text: storefrontReservationEmail.text(emailOpts, lang),
subject: carplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: carplaceReservationEmail.html(emailOpts, lang),
text: carplaceReservationEmail.text(emailOpts, lang),
}).catch(() => null)
sendNotification({
type: 'NEW_BOOKING',
title: 'New Storefront Reservation Request',
title: 'New Carplace Reservation Request',
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
companyId: vehicle.companyId,
channels: ['IN_APP'],
@@ -270,7 +419,7 @@ export async function createStorefrontReservation(body: {
}
}
export async function trackStorefrontFunnelEvent(body: {
export async function trackCarplaceFunnelEvent(body: {
eventName: string
companySlug: string
vehicleId: string
@@ -278,7 +427,7 @@ export async function trackStorefrontFunnelEvent(body: {
path?: string
metadata?: Record<string, string | number | boolean | null>
}, renterId?: string) {
await repo.createStorefrontFunnelEvent({
await repo.createCarplaceFunnelEvent({
...body,
renterId: renterId ?? null,
})
@@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
vi.mock('./storefront.repo', () => ({
vi.mock('./carplace.repo', () => ({
findPublicOffers: vi.fn(),
findCitiesFromCompanies: vi.fn(),
findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleForStorefront: vi.fn(),
findVehicleForStorefrontById: vi.fn(),
upsertMarketplaceCustomer: vi.fn(),
createStorefrontReservation: vi.fn(),
findVehicleForCarplace: vi.fn(),
findVehicleForCarplaceById: vi.fn(),
upsertCarplaceCustomer: vi.fn(),
createCarplaceReservation: vi.fn(),
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
}))
vi.mock('../../lib/emailTranslations', () => ({
storefrontReservationEmail: {
carplaceReservationEmail: {
subject: vi.fn(() => 'Subject'),
html: vi.fn(() => '<html>'),
text: vi.fn(() => 'text'),
},
}))
import * as repo from './storefront.repo'
import * as repo from './carplace.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import {
getCities, searchVehicles, createStorefrontReservation,
getCities, searchVehicles, createCarplaceQuote, createCarplaceReservation,
getReviewContext, submitReview, validateOfferCode,
} from './storefront.service'
} from './carplace.service'
const SLUG = 'test-company'
@@ -80,7 +80,7 @@ describe('getCities', () => {
// ────────────────────────────────────────────────────────────────────────────
describe('searchVehicles', () => {
it('returns vehicles enriched with availability data', async () => {
it('returns published vehicles with an available Carplace status when no dates are selected', async () => {
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
@@ -91,7 +91,7 @@ describe('searchVehicles', () => {
expect(result[0].availabilityStatus).toBe('AVAILABLE')
})
it('passes date range to availability service when provided', async () => {
it('passes the date range to availability and removes unavailable vehicles before pagination', async () => {
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' })
@@ -101,7 +101,33 @@ describe('searchVehicles', () => {
companyId: 'co-1',
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
})
expect(result[0].availability).toBe(false)
expect(result).toEqual([])
})
it('paginates after date-specific availability filtering', async () => {
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
makeVehicle({ id: 'v-1' }),
makeVehicle({ id: 'v-2' }),
makeVehicle({ id: 'v-3' }),
] as any)
vi.mocked(getVehicleAvailabilitySummary).mockImplementation(async (vehicleId) => ({
available: vehicleId !== 'v-1',
status: vehicleId === 'v-1' ? 'RESERVED' : 'AVAILABLE',
nextAvailableAt: null,
blockingReason: vehicleId === 'v-1' ? 'RESERVATION' : null,
}) as any)
const result = await searchVehicles({
startDate: '2026-07-10T10:00:00.000Z',
endDate: '2026-07-12T10:00:00.000Z',
page: 1,
pageSize: 1,
})
expect(result).toHaveLength(1)
expect(result[0].id).toBe('v-2')
})
it('filters out vehicles that do not support different drop-off when requested', async () => {
@@ -119,7 +145,49 @@ describe('searchVehicles', () => {
})
// ────────────────────────────────────────────────────────────────────────────
describe('createStorefrontReservation', () => {
describe('createCarplaceQuote', () => {
const body = {
vehicleId: 'v-1',
startDate: '2026-07-10T10:00:00.000Z',
endDate: '2026-07-13T10:00:00.000Z',
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}
it('returns an availability-aware three-day estimate', async () => {
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await createCarplaceQuote(body)
expect(result.rentalDays).toBe(3)
expect(result.baseSubtotal).toBe(30000)
expect(result.estimatedTotal).toBe(30000)
expect(result.finalPriceRequiresCompanyConfirmation).toBe(true)
})
it('calculates fixed and free-day promotions instead of assuming every offer is a percentage', async () => {
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'FREE_DAY', discountValue: 1, title: 'One free day', maxRedemptions: null, redemptionCount: 0 } as any)
const result = await createCarplaceQuote({ ...body, promoCode: 'FREE1' })
expect(result.discounts).toEqual([{ label: 'One free day', amount: 10000 }])
expect(result.estimatedTotal).toBe(20000)
})
it('rejects a promotion owned by a different rental company', async () => {
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'other-company', type: 'PERCENTAGE', discountValue: 10, title: 'Wrong company' } as any)
await expect(createCarplaceQuote({ ...body, promoCode: 'WRONG' })).rejects.toMatchObject({ error: 'offer_invalid' })
})
})
// ────────────────────────────────────────────────────────────────────────────
describe('createCarplaceReservation', () => {
const baseBody = {
vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
@@ -137,49 +205,63 @@ describe('createStorefrontReservation', () => {
}
it('creates reservation and returns reservationId', async () => {
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createStorefrontReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
const result = await createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
const result = await createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
expect(result.reservationId).toBe('r-1')
expect(result.bookingReference).toBe('BK-2026-ABC123')
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
expect(repo.upsertCarplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
identityDocumentNumber: 'CIN123456',
fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456',
}))
expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}))
})
it('throws NotFoundError when vehicle not found', async () => {
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(null)
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(null)
await expect(createStorefrontReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
await expect(createCarplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
})
it('throws AppError when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createStorefrontReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
await expect(createCarplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('rejects different return locations when the vehicle requires same drop-off', async () => {
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
await expect(
createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
createCarplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).rejects.toMatchObject({ error: 'same_dropoff_required' })
})
it('stores the same discounted total shown by the Carplace quote', async () => {
vi.mocked(repo.findVehicleForCarplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.findOfferByCode).mockResolvedValue({ companyId: 'co-1', type: 'PERCENTAGE', discountValue: 10, title: 'Ten percent', maxRedemptions: null, redemptionCount: 0 } as any)
vi.mocked(repo.upsertCarplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createCarplaceReservation).mockResolvedValue({ id: 'r-2', bookingReference: 'BK-2026-PROMO1' } as any)
await createCarplaceReservation({ ...baseBody, promoCode: 'SAVE10' })
expect(repo.createCarplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
totalAmount: 27000,
}))
})
it('throws AppError for invalid date range', async () => {
await expect(
createStorefrontReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
createCarplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
).rejects.toMatchObject({ error: 'invalid_dates' })
})
})
@@ -14,7 +14,7 @@ const fullBrand = {
amanpaySecretKey: 'super-secret-key',
paypalEmail: 'paypal@example.com',
paypalMerchantId: 'paypal-merchant-xyz',
isListedOnMarketplace: true,
isListedOnCarplace: true,
}
describe('presentBrand', () => {
@@ -84,7 +84,7 @@ describe('presentBrand', () => {
primaryColor: '#1A56DB',
defaultLocale: 'en',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
isListedOnCarplace: true,
})
})
@@ -26,7 +26,7 @@ export const brandSchema = z.object({
amanpaySecretKey: z.string().optional(),
paypalEmail: optionalEmailField(),
paypalMerchantId: z.string().optional(),
isListedOnMarketplace: z.boolean().optional(),
isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.object({
heroTitle: z.union([z.string(), z.null()]).optional(),
heroDescription: z.union([z.string(), z.null()]).optional(),
@@ -38,7 +38,7 @@ const editableSettingsFeatures = [
'settings.company_profile',
'settings.public_contact',
'settings.locale_currency',
'settings.storefront_listing',
'settings.carplace_listing',
'settings.branding_basic',
'settings.branding_custom',
'settings.branding_hero',
@@ -16,7 +16,7 @@ const editableSettingsFeatures = [
'settings.company_profile',
'settings.public_contact',
'settings.locale_currency',
'settings.storefront_listing',
'settings.carplace_listing',
'settings.branding_basic',
'settings.branding_custom',
'settings.branding_hero',
@@ -51,7 +51,7 @@ describe('settingsEntitlements', () => {
expect(result.items).toHaveLength(7)
expect(result.items.map((item) => item.sectionKey)).toEqual([
'company',
'storefront',
'carplace',
'payments',
'rental-policies',
'insurance',
@@ -7,7 +7,7 @@ export type SettingsFeatureKey =
| 'settings.company_profile'
| 'settings.public_contact'
| 'settings.locale_currency'
| 'settings.storefront_listing'
| 'settings.carplace_listing'
| 'settings.branding_basic'
| 'settings.branding_custom'
| 'settings.branding_hero'
@@ -22,7 +22,7 @@ export type SettingsFeatureKey =
export type SettingsSectionKey =
| 'company'
| 'storefront'
| 'carplace'
| 'payments'
| 'rental-policies'
| 'insurance'
@@ -51,7 +51,7 @@ const PRO_ONLY: Plan[] = ['PRO']
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
en: {
company: { label: 'Company Profile', description: 'Manage public company details and defaults.' },
storefront: { label: 'Branding and Storefront', description: 'Control marketplace listing, logo, colors, and storefront media.' },
carplace: { label: 'Branding and Carplace', description: 'Control Carplace listing, logo, colors, and media.' },
payments: { label: 'Payment Methods', description: 'Configure renter payment providers.' },
'rental-policies': { label: 'Rental Policies', description: 'Set fuel, damage, and additional-driver policies.' },
insurance: { label: 'Insurance Policies', description: 'Manage optional and required insurance products.' },
@@ -60,7 +60,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
},
fr: {
company: { label: 'Profil entreprise', description: 'Gérez les informations publiques et les valeurs par défaut.' },
storefront: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
carplace: { label: 'Marque et vitrine', description: 'Contrôlez la publication, le logo, les couleurs et les médias.' },
payments: { label: 'Méthodes de paiement', description: 'Configurez les prestataires de paiement des locataires.' },
'rental-policies': { label: 'Politiques de location', description: 'Définissez les règles carburant, dommages et conducteurs.' },
insurance: { label: 'Polices dassurance', description: 'Gérez les assurances optionnelles et obligatoires.' },
@@ -69,7 +69,7 @@ const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; d
},
ar: {
company: { label: 'ملف الشركة', description: 'إدارة بيانات الشركة العامة والإعدادات الافتراضية.' },
storefront: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
carplace: { label: 'العلامة والواجهة', description: 'التحكم في الظهور والشعار والألوان ووسائط الواجهة.' },
payments: { label: 'طرق الدفع', description: 'إعداد مزودي دفع المستأجرين.' },
'rental-policies': { label: 'سياسات الإيجار', description: 'ضبط سياسات الوقود والأضرار والسائق الإضافي.' },
insurance: { label: 'سياسات التأمين', description: 'إدارة منتجات التأمين الاختيارية والإلزامية.' },
@@ -87,7 +87,7 @@ export const SETTINGS_MENU_CATALOG: Array<{
requiredPlan: Plan | null
}> = [
{ menuKey: 'company', sectionKey: 'company', iconKey: 'building', sortOrder: 10, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'storefront', sectionKey: 'storefront', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'carplace', sectionKey: 'carplace', iconKey: 'palette', sortOrder: 20, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'payments', sectionKey: 'payments', iconKey: 'credit-card', sortOrder: 30, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
{ menuKey: 'rental-policies', sectionKey: 'rental-policies', iconKey: 'file-text', sortOrder: 40, plans: ALL_PLANS, requiredPlan: null },
{ menuKey: 'insurance', sectionKey: 'insurance', iconKey: 'shield', sortOrder: 50, plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
@@ -99,7 +99,7 @@ const FEATURE_PLANS: Record<SettingsFeatureKey, { plans: Plan[]; requiredPlan: P
'settings.company_profile': { plans: ALL_PLANS, requiredPlan: null },
'settings.public_contact': { plans: ALL_PLANS, requiredPlan: null },
'settings.locale_currency': { plans: ALL_PLANS, requiredPlan: null },
'settings.storefront_listing': { plans: ALL_PLANS, requiredPlan: null },
'settings.carplace_listing': { plans: ALL_PLANS, requiredPlan: null },
'settings.branding_basic': { plans: ALL_PLANS, requiredPlan: null },
'settings.branding_custom': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
'settings.branding_hero': { plans: GROWTH_PLUS, requiredPlan: 'GROWTH' },
@@ -149,7 +149,7 @@ function statusAllowsEditing(status: SubscriptionStatus, featureKey: SettingsFea
'settings.company_profile',
'settings.public_contact',
'settings.locale_currency',
'settings.storefront_listing',
'settings.carplace_listing',
'settings.branding_basic',
'settings.rental_policies_basic',
].includes(featureKey)
@@ -96,7 +96,7 @@ export async function confirmReservation(id: string, companyId: string) {
},
}).catch(() => null)
if (reservation.source === 'MARKETPLACE') {
if (reservation.source === 'CARPLACE') {
const progress = buildBookingRequestProgress({
source: reservation.source,
status: 'CONFIRMED',
@@ -193,7 +193,7 @@ export async function closeReservation(id: string, companyId: string, closedBy:
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
sendTransactionalEmail({
to: customer.email,
@@ -60,7 +60,7 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({
id: 'reservation_1',
status: 'DRAFT',
source: 'MARKETPLACE',
source: 'CARPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
@@ -84,7 +84,7 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({
id: 'reservation_1',
status: 'DRAFT',
source: 'MARKETPLACE',
source: 'CARPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
@@ -19,7 +19,7 @@ describe('serializeReservationForDashboard', () => {
const result = serializeReservationForDashboard({
id: 'reservation-1',
status: 'CONFIRMED',
source: 'MARKETPLACE',
source: 'CARPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
@@ -68,8 +68,8 @@ export function buildBookingRequestProgress(reservation: {
}
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
const documentsRequired = reservation.source === 'CARPLACE' && companyConfirmed && documentsMissing.length > 0
const paymentRequired = reservation.source === 'CARPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
@@ -49,7 +49,7 @@ describe('review.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
process.env.STOREFRONT_URL = 'https://market.example'
process.env.CARPLACE_URL = 'https://market.example'
})
it('returns reviews with rating filters and pagination metadata', async () => {
@@ -61,7 +61,7 @@ export async function sendReviewReminder(id: string, companyId: string) {
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicle = reservation.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
const reviewUrl = `${process.env.STOREFRONT_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
const reviewUrl = `${process.env.CARPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
await sendTransactionalEmail({
to: customer.email,
@@ -18,7 +18,7 @@ describe('site.presenter', () => {
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
isListedOnCarplace: true,
},
})
@@ -32,7 +32,7 @@ describe('site.presenter', () => {
paymentMethodsEnabled: ['PAYPAL'],
defaultLocale: 'fr',
defaultCurrency: 'MAD',
isListedOnMarketplace: true,
isListedOnCarplace: true,
},
})
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
+2 -2
View File
@@ -30,8 +30,8 @@ export function presentBrand(company: {
paypalEmail: brand.paypalEmail,
paypalMerchantId: brand.paypalMerchantId,
paymentMethodsEnabled: brand.paymentMethodsEnabled,
isListedOnMarketplace: brand.isListedOnMarketplace,
marketplaceRating: brand.marketplaceRating,
isListedOnCarplace: brand.isListedOnCarplace,
carplaceRating: brand.carplaceRating,
homePageConfig: brand.homePageConfig,
menuConfig: brand.menuConfig,
} : null,
+1 -1
View File
@@ -35,7 +35,7 @@ export const bookSchema = z.object({
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
notes: z.string().optional(),
source: z.enum(['PUBLIC_SITE', 'MARKETPLACE']).default('PUBLIC_SITE'),
source: z.enum(['PUBLIC_SITE', 'CARPLACE']).default('PUBLIC_SITE'),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(z.object({
firstName: z.string().min(1),
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
planFeature: { findMany: vi.fn() },
},
}))
vi.mock('../../services/platformContentService', () => ({ getStorefrontHomepageContent: vi.fn() }))
vi.mock('../../services/platformContentService', () => ({ getCarplaceHomepageContent: vi.fn() }))
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
+3 -3
View File
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService'
import { getStorefrontHomepageContent } from '../../services/platformContentService'
import { getCarplaceHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
@@ -30,7 +30,7 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
allowedHosts.add('127.0.0.1:3000')
allowedHosts.add('127.0.0.1:4000')
}
for (const value of [process.env.STOREFRONT_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_STOREFRONT_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
for (const value of [process.env.CARPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_CARPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
if (!value) continue
try { allowedHosts.add(new URL(value).host) } catch {}
}
@@ -53,7 +53,7 @@ function generateBookingReference() {
}
export async function getPlatformHomepage() {
return getStorefrontHomepageContent()
return getCarplaceHomepageContent()
}
export async function getPlatformPricing() {
+1 -1
View File
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
}))
vi.mock('../../services/platformContentService', () => ({
getStorefrontHomepageContent: vi.fn(),
getCarplaceHomepageContent: vi.fn(),
}))
import * as repo from './site.repo'
@@ -1,176 +0,0 @@
import { Router } from 'express'
import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseParams, parseQuery } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './storefront.service'
import {
paginationSchema, searchSchema, companiesQuerySchema, storefrontReservationSchema,
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
} from './storefront.schemas'
const router = Router()
router.use(optionalRenterAuth)
router.get('/offers', async (_req, res, next) => {
try {
ok(res, await service.getPublicOffers())
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/cities', async (_req, res, next) => {
try {
ok(res, await service.getCities())
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/companies', async (req, res, next) => {
try {
const { city, hasOffer } = parseQuery(companiesQuerySchema, req)
const { page, pageSize } = parseQuery(paginationSchema, req)
ok(res, await service.getListedCompanies({ city, hasOffer, page, pageSize }))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/search', async (req, res, next) => {
try {
const filters = parseQuery(searchSchema, req)
const pagination = parseQuery(paginationSchema, req)
ok(res, await service.searchVehicles({ ...filters, ...pagination }))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.post('/reservations', async (req, res, next) => {
try {
const body = parseBody(storefrontReservationSchema, req)
const result = await service.createStorefrontReservation(body)
created(res, result)
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Service temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.post('/events', async (req, res, next) => {
try {
const body = parseBody(marketplaceFunnelEventSchema, req)
ok(res, await service.trackStorefrontFunnelEvent(body, req.renterId))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/review/:token', async (req, res, next) => {
try {
const { token } = parseParams(reviewTokenSchema, req)
ok(res, await service.getReviewContext(token))
} catch (err) { next(err) }
})
router.post('/review/:token', async (req, res, next) => {
try {
const { token } = parseParams(reviewTokenSchema, req)
const body = parseBody(reviewBodySchema, req)
const result = await service.submitReview(token, body)
created(res, result)
} catch (err) { next(err) }
})
router.post('/offers/:code/validate', async (req, res, next) => {
try {
const { code } = parseParams(offerCodeParamSchema, req)
ok(res, await service.validateOfferCode(code))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Offer validation is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyPage(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Storefront data is temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/reviews', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyReviews(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyVehicles(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const { slug, id } = parseParams(vehicleParamSchema, req)
ok(res, await service.getVehicleDetail(slug, id))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
try {
const { slug, id } = parseParams(vehicleParamSchema, req)
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
ok(res, await service.getStorefrontVehicleAvailability(slug, id, { startDate, endDate }))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/offers', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
ok(res, await service.getCompanyOffers(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
export default router
@@ -49,7 +49,7 @@ describe('financialReportService', () => {
pricingRulesTotal: 10,
depositAmount: 500,
totalAmount: 640,
source: 'MARKETPLACE',
source: 'CARPLACE',
startDate: new Date('2026-06-18T10:00:00.000Z'),
endDate: new Date('2026-06-20T10:00:00.000Z'),
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
@@ -27,15 +27,15 @@ describe('platformContentService', () => {
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
const service = await importFreshService()
const first = await service.getStorefrontHomepageContent()
const first = await service.getCarplaceHomepageContent()
first.en.heroTitle = 'Mutated by test'
const second = await service.getStorefrontHomepageContent()
const second = await service.getCarplaceHomepageContent()
expect(second.en.sections).toContain('hero')
expect(second.en.heroTitle).not.toBe('Mutated by test')
})
it('saves only the storefront homepage field while preserving unrelated platform content fields', async () => {
it('saves only the Carplace homepage field while preserving unrelated platform content fields', async () => {
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
})
const service = await importFreshService()
const homepage = await service.getStorefrontHomepageContent()
homepage.en.heroTitle = 'A better storefront title'
const homepage = await service.getCarplaceHomepageContent()
homepage.en.heroTitle = 'A better Carplace title'
await expect(service.saveStorefrontHomepageContent(homepage)).resolves.toBe(homepage)
await expect(service.saveCarplaceHomepageContent(homepage)).resolves.toBe(homepage)
const raw = JSON.parse(await readFile(dataPath, 'utf8'))
expect(raw.otherSetting).toEqual({ keep: true })
expect(raw.storefrontHomepage.en.heroTitle).toBe('A better storefront title')
expect(raw.carplaceHomepage.en.heroTitle).toBe('A better Carplace title')
})
})
@@ -1,6 +1,9 @@
import { mkdir, readFile, writeFile } from 'fs/promises'
import path from 'path'
import { cloneStorefrontHomepageContent, type StorefrontHomepageConfig } from '@rentaldrivego/types'
import {
cloneCarplaceHomepageContent,
type CarplaceHomepageConfig,
} from '@rentaldrivego/types'
function resolveContentPath() {
const cwd = process.cwd()
@@ -12,7 +15,7 @@ function resolveContentPath() {
const platformContentPath = resolveContentPath()
type PlatformContentFile = {
storefrontHomepage?: StorefrontHomepageConfig
carplaceHomepage?: CarplaceHomepageConfig
}
async function readPlatformContentFile(): Promise<PlatformContentFile> {
@@ -29,16 +32,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
}
export async function getStorefrontHomepageContent() {
export async function getCarplaceHomepageContent() {
const content = await readPlatformContentFile()
return content.storefrontHomepage ?? cloneStorefrontHomepageContent()
return content.carplaceHomepage ?? cloneCarplaceHomepageContent()
}
export async function saveStorefrontHomepageContent(homepage: StorefrontHomepageConfig) {
export async function saveCarplaceHomepageContent(homepage: CarplaceHomepageConfig) {
const content = await readPlatformContentFile()
await writePlatformContentFile({
...content,
storefrontHomepage: homepage,
carplaceHomepage: homepage,
})
return homepage
}
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
}),
}))
expect(openApiDocument.tags).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Storefront' }),
expect.objectContaining({ name: 'Carplace' }),
expect.objectContaining({ name: 'Subscriptions' }),
expect.objectContaining({ name: 'Admin' }),
]))
+27 -27
View File
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
{ name: 'Companies', description: 'Company profile & settings' },
{ name: 'Team', description: 'Employee management' },
{ name: 'Subscriptions', description: 'Plan management & billing' },
{ name: 'Storefront', description: 'Public booking portal' },
{ name: 'Carplace', description: 'Public booking portal' },
{ name: 'Site', description: 'White-label site API' },
{ name: 'Admin', description: 'Platform administration' },
],
@@ -996,20 +996,20 @@ export const openApiDocument: JsonObject = {
},
// ════════════════════════════════════════════════════════════════
// MARKETPLACE (public — no auth required)
// CARPLACE (public — no auth required)
// ════════════════════════════════════════════════════════════════
'/storefront/cities': {
get: { tags: ['Storefront'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
'/carplace/cities': {
get: { tags: ['Carplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
},
'/storefront/companies': {
get: { tags: ['Storefront'], summary: 'List companies', security: [], responses: { '200': ok } },
'/carplace/companies': {
get: { tags: ['Carplace'], summary: 'List companies', security: [], responses: { '200': ok } },
},
'/storefront/offers': {
get: { tags: ['Storefront'], summary: 'Public offers', security: [], responses: { '200': ok } },
'/carplace/offers': {
get: { tags: ['Carplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
},
'/storefront/search': {
'/carplace/search': {
get: {
tags: ['Storefront'],
tags: ['Carplace'],
summary: 'Search available vehicles',
security: [],
parameters: [
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
responses: { '200': ok },
},
},
'/storefront/reservations': {
post: { tags: ['Storefront'], summary: 'Create storefront booking', security: [], responses: { '201': ok } },
'/carplace/reservations': {
post: { tags: ['Carplace'], summary: 'Create carplace booking', security: [], responses: { '201': ok } },
},
'/storefront/offers/{code}/validate': {
post: { tags: ['Storefront'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/offers/{code}/validate': {
post: { tags: ['Carplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/storefront/review/{token}': {
get: { tags: ['Storefront'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
post: { tags: ['Storefront'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/review/{token}': {
get: { tags: ['Carplace'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
post: { tags: ['Carplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/storefront/{slug}': {
get: { tags: ['Storefront'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}': {
get: { tags: ['Carplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/storefront/{slug}/reviews': {
get: { tags: ['Storefront'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}/reviews': {
get: { tags: ['Carplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/storefront/{slug}/vehicles': {
get: { tags: ['Storefront'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}/vehicles': {
get: { tags: ['Carplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/storefront/{slug}/vehicles/{id}': {
get: { tags: ['Storefront'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
'/carplace/{slug}/vehicles/{id}': {
get: { tags: ['Carplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
},
'/storefront/{slug}/offers': {
get: { tags: ['Storefront'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/carplace/{slug}/offers': {
get: { tags: ['Carplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
// ════════════════════════════════════════════════════════════════
@@ -40,12 +40,12 @@ vi.mock('../../modules/notifications/notification.service', () => ({
getRenterPreferences: vi.fn(),
setRenterPreferences: vi.fn(),
}))
vi.mock('../../modules/storefront/storefront.service', () => ({
vi.mock('../../modules/carplace/carplace.service', () => ({
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
getVehicleDetail: vi.fn(),
createStorefrontReservation: vi.fn(),
createCarplaceReservation: vi.fn(),
getCompanyPage: vi.fn(),
getCompanyReviews: vi.fn(),
getCompanyVehicles: vi.fn(),
@@ -61,11 +61,11 @@ import request from 'supertest'
import { createApp } from '../../app'
import * as employeeService from '../../modules/auth/auth.employee.service'
import * as notificationService from '../../modules/notifications/notification.service'
import * as storefrontService from '../../modules/storefront/storefront.service'
import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp()
describe('employee, notification, and storefront API validation contracts', () => {
describe('employee, notification, and carplace API validation contracts', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
@@ -73,8 +73,8 @@ describe('employee, notification, and storefront API validation contracts', () =
vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never)
vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never)
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
vi.mocked(carplaceService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
})
it('normalizes employee login email before service execution', async () => {
@@ -110,11 +110,11 @@ describe('employee, notification, and storefront API validation contracts', () =
expect(notificationService.setPreferences).not.toHaveBeenCalled()
})
it('defaults storefront search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/storefront/search?city=Casablanca')
it('defaults carplace search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/carplace/search?city=Casablanca')
expect(res.status).toBe(200)
expect(storefrontService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
expect(carplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
city: 'Casablanca',
page: 1,
pageSize: 20,
@@ -122,7 +122,7 @@ describe('employee, notification, and storefront API validation contracts', () =
})
it('rejects out-of-range public review ratings before service execution', async () => {
const res = await request(app).post('/api/v1/storefront/review/token_123').send({
const res = await request(app).post('/api/v1/carplace/review/token_123').send({
overallRating: 6,
vehicleRating: 5,
serviceRating: 5,
@@ -130,6 +130,6 @@ describe('employee, notification, and storefront API validation contracts', () =
})
expect(res.status).toBe(400)
expect(storefrontService.submitReview).not.toHaveBeenCalled()
expect(carplaceService.submitReview).not.toHaveBeenCalled()
})
})
@@ -30,12 +30,12 @@ vi.mock('../../modules/site/site.service', () => ({
handleContact: vi.fn(),
}))
vi.mock('../../modules/storefront/storefront.service', () => ({
vi.mock('../../modules/carplace/carplace.service', () => ({
getPublicOffers: vi.fn(),
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
createStorefrontReservation: vi.fn(),
createCarplaceReservation: vi.fn(),
getReviewContext: vi.fn(),
submitReview: vi.fn(),
validateOfferCode: vi.fn(),
@@ -49,7 +49,7 @@ vi.mock('../../modules/storefront/storefront.service', () => ({
import request from 'supertest'
import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service'
import * as storefrontService from '../../modules/storefront/storefront.service'
import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp()
@@ -59,9 +59,9 @@ describe('public validation API contracts', () => {
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never)
vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never)
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
vi.mocked(storefrontService.createStorefrontReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
vi.mocked(carplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
vi.mocked(carplaceService.createCarplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
})
it('rejects malformed public availability checks before site service execution', async () => {
@@ -120,11 +120,11 @@ describe('public validation API contracts', () => {
})
})
it('coerces storefront search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/storefront/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
it('coerces carplace search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/carplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
expect(res.status).toBe(200)
expect(storefrontService.searchVehicles).toHaveBeenCalledWith({
expect(carplaceService.searchVehicles).toHaveBeenCalledWith({
city: 'Rabat',
maxPrice: 500,
page: 2,
@@ -132,15 +132,15 @@ describe('public validation API contracts', () => {
})
})
it('rejects oversized storefront pagination before search execution', async () => {
const res = await request(app).get('/api/v1/storefront/search?pageSize=500')
it('rejects oversized carplace pagination before search execution', async () => {
const res = await request(app).get('/api/v1/carplace/search?pageSize=500')
expect(res.status).toBe(400)
expect(storefrontService.searchVehicles).not.toHaveBeenCalled()
expect(carplaceService.searchVehicles).not.toHaveBeenCalled()
})
it('rejects storefront reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/storefront/reservations').send({
it('rejects carplace reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/carplace/reservations').send({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
@@ -151,13 +151,13 @@ describe('public validation API contracts', () => {
})
expect(res.status).toBe(400)
expect(storefrontService.createStorefrontReservation).not.toHaveBeenCalled()
expect(carplaceService.createCarplaceReservation).not.toHaveBeenCalled()
})
it('rejects invalid public review ratings before creating reviews', async () => {
const res = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 6 })
const res = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 6 })
expect(res.status).toBe(400)
expect(storefrontService.submitReview).not.toHaveBeenCalled()
expect(carplaceService.submitReview).not.toHaveBeenCalled()
})
})
@@ -10,7 +10,7 @@ import { createApp } from '../../app'
const app = createApp()
describe('employee and storefront public boundary smoke', () => {
describe('employee and carplace public boundary smoke', () => {
it('rejects malformed employee login payloads without leaking internals', async () => {
const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' })
@@ -19,7 +19,7 @@ describe('employee and storefront public boundary smoke', () => {
})
it('rejects invalid public review payloads without executing private infrastructure', async () => {
const res = await request(app).post('/api/v1/storefront/review/token_123').send({ overallRating: 0 })
const res = await request(app).post('/api/v1/carplace/review/token_123').send({ overallRating: 0 })
expect(res.status).toBe(400)
expect(JSON.stringify(res.body)).not.toContain('Prisma')
@@ -24,12 +24,12 @@ vi.mock('../../modules/site/site.service', () => ({
capturePaypal: vi.fn(),
handleContact: vi.fn(),
}))
vi.mock('../../modules/storefront/storefront.service', () => ({
vi.mock('../../modules/carplace/carplace.service', () => ({
getPublicOffers: vi.fn(),
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
createStorefrontReservation: vi.fn(),
createCarplaceReservation: vi.fn(),
getReviewContext: vi.fn(),
submitReview: vi.fn(),
validateOfferCode: vi.fn(),
@@ -43,7 +43,7 @@ vi.mock('../../modules/storefront/storefront.service', () => ({
import request from 'supertest'
import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service'
import * as storefrontService from '../../modules/storefront/storefront.service'
import * as carplaceService from '../../modules/carplace/carplace.service'
const app = createApp()
@@ -61,8 +61,8 @@ describe('public validation e2e smoke', () => {
expect(payment.status).toBe(400)
expect(siteService.initPayment).not.toHaveBeenCalled()
const review = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 0 })
const review = await request(app).post('/api/v1/carplace/review/token_1').send({ overallRating: 0 })
expect(review.status).toBe(400)
expect(storefrontService.submitReview).not.toHaveBeenCalled()
expect(carplaceService.submitReview).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
describe('billing, storefront, and vehicle integration boundaries', () => {
describe('billing, carplace, and vehicle integration boundaries', () => {
it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => {
expect([
'admin billing invoice lifecycle uses persisted line items and audit logs',
'storefront company pages enrich vehicles with real availability records',
'carplace company pages enrich vehicles with real availability records',
'vehicle location settings persist through create/update/list flows',
]).toHaveLength(3)
})
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest'
describe('employee, storefront, and notification integration boundaries', () => {
describe('employee, carplace, and notification integration boundaries', () => {
it('documents the Batch-17 DB-backed integration boundary', () => {
expect({
employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles',
storefront: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
carplace: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
notifications: 'preference persistence requires notificationPreference composite indexes',
}).toEqual(expect.objectContaining({
employeeAuth: expect.stringContaining('Prisma'),
storefront: expect.stringContaining('seeded'),
carplace: expect.stringContaining('seeded'),
notifications: expect.stringContaining('composite'),
}))
})
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parseBody, parseQuery } from '../../http/validate'
import { storefrontReservationSchema, paginationSchema } from '../../modules/storefront/storefront.schemas'
import { carplaceReservationSchema, paginationSchema } from '../../modules/carplace/carplace.schemas'
import { paySchema } from '../../modules/site/site.schemas'
function reqWith(body: unknown, query: unknown = {}) {
@@ -8,7 +8,7 @@ function reqWith(body: unknown, query: unknown = {}) {
}
describe('public validation boundaries', () => {
it('keeps storefront pagination coercion consistent with route parsing', () => {
it('keeps carplace pagination coercion consistent with route parsing', () => {
expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({
page: 4,
pageSize: 25,
@@ -24,8 +24,8 @@ describe('public validation boundaries', () => {
}))).toThrow('Invalid enum value')
})
it('requires enough anonymous storefront reservation identity to create a booking request', () => {
const parsed = parseBody(storefrontReservationSchema, reqWith({
it('requires enough anonymous carplace reservation identity to create a booking request', () => {
const parsed = parseBody(carplaceReservationSchema, reqWith({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
+15
View File
@@ -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
+37
View File
@@ -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
@@ -1,5 +1,5 @@
{
"name": "@rentaldrivego/storefront",
"name": "@rentaldrivego/carplace",
"version": "1.0.0",
"private": true,
"scripts": {

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 302 B

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Before

Width:  |  Height:  |  Size: 836 KiB

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() }
@@ -16,7 +16,7 @@ function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string
expect(page.props.forcedLanguage).toBe(forcedLanguage)
}
describe('storefront static app policy pages', () => {
describe('carplace static app policy pages', () => {
it('binds app privacy pages to explicit locales', () => {
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
@@ -13,7 +13,7 @@ import FooterContentPage from '@/components/FooterContentPage'
import FooterPage, { generateStaticParams } from './page'
import { footerPageSlugs } from '@/lib/footerContent'
describe('storefront footer route', () => {
describe('carplace footer route', () => {
it('generates one static route per registered footer slug', () => {
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
})
+24
View File
@@ -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>
)
}
+158
View File
@@ -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>
}
+240
View File
@@ -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; }
}
+52
View File
@@ -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>
)
}
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './StorefrontShell'
import { getFooterContent, localeOptions } from './CarplaceShell'
describe('StorefrontShell footer content registry', () => {
it('exposes exactly the supported storefront locales in selector order', () => {
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)
})
@@ -12,16 +12,16 @@ describe('StorefrontShell footer content registry', () => {
expect(content.localeLabel).toBe('Global (English)')
expect(content.rightsLabel).toBe('All rights reserved.')
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/app-privacy-en' })
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
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: '/footer/terms-of-service' })
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
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', () => {
@@ -29,7 +29,7 @@ describe('StorefrontShell footer content registry', () => {
expect(content.localeLabel).toBe('North Africa (Arabic)')
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/app-privacy-ar' })
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>
}
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/StorefrontShell', () => ({
useStorefrontPreferences: () => ({ language: preferenceState.language, theme: 'light' }),
vi.mock('@/components/CarplaceShell', () => ({
useCarplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
}))
import FooterContentPage from './FooterContentPage'
@@ -29,7 +29,7 @@ describe('FooterContentPage', () => {
preferenceState.language = 'en'
})
it('uses the current storefront language when no forced language is provided', () => {
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(' ')
@@ -2,9 +2,9 @@
import React from 'react'
import { useStorefrontPreferences } from '@/components/StorefrontShell'
import { useCarplacePreferences } from '@/components/CarplaceShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type StorefrontLanguage } from '@/lib/i18n'
import { type CarplaceLanguage } from '@/lib/i18n'
const pageMeta = {
en: {
@@ -53,8 +53,8 @@ function renderParagraphText(paragraph: string) {
})
}
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: StorefrontLanguage }) {
const { language } = useStorefrontPreferences()
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]
@@ -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>
)
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { StorefrontApiError, storefrontFetch, storefrontFetchOrDefault, storefrontPost } from './api'
import { CarplaceApiError, carplaceFetch, carplaceFetchOrDefault, carplacePost } from './api'
afterEach(() => {
delete process.env.NEXT_PUBLIC_API_URL
@@ -8,19 +8,19 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'fetch')
})
describe('storefront API helpers', () => {
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(storefrontFetch('/storefront/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/storefront\/vehicles$/), {
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 storefront API errors', async () => {
it('throws rich carplace API errors', async () => {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({
@@ -30,8 +30,8 @@ describe('storefront API helpers', () => {
})),
})
await expect(storefrontFetch('/storefront/book')).rejects.toMatchObject({
name: 'StorefrontApiError',
await expect(carplaceFetch('/carplace/book')).rejects.toMatchObject({
name: 'CarplaceApiError',
message: 'Vehicle unavailable',
status: 409,
code: 'VEHICLE_UNAVAILABLE',
@@ -45,7 +45,7 @@ describe('storefront API helpers', () => {
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })),
})
await expect(storefrontFetchOrDefault('/storefront/homepage', { hero: null })).resolves.toEqual({ hero: null })
await expect(carplaceFetchOrDefault('/carplace/homepage', { hero: null })).resolves.toEqual({ hero: null })
})
it('posts JSON payloads and unwraps successful responses', async () => {
@@ -53,7 +53,7 @@ describe('storefront API helpers', () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
await expect(storefrontPost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
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',
@@ -68,8 +68,8 @@ describe('storefront API helpers', () => {
value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })),
})
await expect(storefrontFetch('/site/homepage')).rejects.toMatchObject({
name: 'StorefrontApiError',
await expect(carplaceFetch('/site/homepage')).rejects.toMatchObject({
name: 'CarplaceApiError',
message: 'Request failed',
status: 502,
})
+71
View File
@@ -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)
}
@@ -12,7 +12,7 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('storefront app URL resolution', () => {
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')
})
@@ -29,7 +29,7 @@ describe('storefront app URL resolution', () => {
it('keeps path prefixes while rewriting the browser origin', () => {
installWindow('rental.example.com', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3000/storefront')).toBe('https://rental.example.com/storefront')
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', () => {
+127
View File
@@ -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, lassurance, 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 sagit dun sous-total estimé. Des taxes, une caution, une assurance, la livraison ou dautres frais peuvent sappliquer.'],
["Ai-je besoin d'un compte client ?", 'Non. Carplace propose actuellement un parcours de demande en tant quinvité.'],
],
vehicle: { from: 'À partir de', perDay: '/ jour', estimated: 'Sous-total estimé', estimateDisclaimer: 'Le prix final, la caution, lassurance, 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 dautres 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: 'Jaccepte 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 lexaminer et vous contacter. Le véhicule nest confirmé quaprè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
}
+18
View File
@@ -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}%`
}
+19
View File
@@ -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}`
}
+113
View File
@@ -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
}
@@ -7,17 +7,17 @@ import {
getFooterPageContent,
isFooterPageSlug,
} from './footerContent'
import type { StorefrontLanguage } from './i18n'
import type { CarplaceLanguage } from './i18n'
const languages: StorefrontLanguage[] = ['en', 'fr', 'ar']
const languages: CarplaceLanguage[] = ['en', 'fr', 'ar']
describe('storefront footer content registry', () => {
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(`/footer/${slug}`)
expect(footerPageHref[slug]).toBe(`/carplace/footer/${slug}`)
}
expect(isFooterPageSlug('privacy')).toBe(false)
@@ -42,16 +42,16 @@ describe('storefront footer content registry', () => {
})
it('keeps website privacy and application privacy links separate by locale', () => {
expect(footerPageHref['privacy-policy']).toBe('/footer/privacy-policy')
expect(footerPageHref['privacy-policy']).toBe('/carplace/footer/privacy-policy')
expect(appPrivacyHref).toEqual({
en: '/app-privacy-en',
fr: '/app-privacy-fr',
ar: '/app-privacy-ar',
en: '/carplace/app-privacy-en',
fr: '/carplace/app-privacy-fr',
ar: '/carplace/app-privacy-ar',
})
expect(appTermsHref).toEqual({
en: '/app-tc-en',
fr: '/app-tc-fr',
ar: '/app-tc-ar',
en: '/carplace/app-tc-en',
fr: '/carplace/app-tc-fr',
ar: '/carplace/app-tc-ar',
})
})

Some files were not shown because too many files have changed in this diff Show More