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
@@ -49,11 +49,10 @@ NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||
# Required for production builds. Must be an absolute https:// URL with no trailing slash.
|
||||
SITE_ORIGIN=https://rentaldrivego.ma
|
||||
|
||||
# Marketplace/storefront public routes sit at the domain root.
|
||||
# Traefik routes /explore, /renter, /sign-in, /footer, and app policy pages to the storefront app.
|
||||
# Carplace public routes sit under /carplace on the public site domain.
|
||||
NEXT_PUBLIC_MARKETING_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_STOREFRONT_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_CARPLACE_URL=https://rentaldrivego.ma/carplace
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=https://rentaldrivego.ma
|
||||
|
||||
@@ -18,7 +18,7 @@ ARG ADMIN_INTERNAL_URL=http://admin:3002
|
||||
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_HOMEPAGE_URL
|
||||
ARG NEXT_PUBLIC_STOREFRONT_URL
|
||||
ARG NEXT_PUBLIC_CARPLACE_URL
|
||||
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_URL
|
||||
|
||||
@@ -31,7 +31,7 @@ ENV API_INTERNAL_URL=$API_INTERNAL_URL \
|
||||
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
|
||||
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
NEXT_PUBLIC_HOMEPAGE_URL=$NEXT_PUBLIC_HOMEPAGE_URL \
|
||||
NEXT_PUBLIC_STOREFRONT_URL=$NEXT_PUBLIC_STOREFRONT_URL \
|
||||
NEXT_PUBLIC_CARPLACE_URL=$NEXT_PUBLIC_CARPLACE_URL \
|
||||
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
|
||||
SITE_ORIGIN=$SITE_ORIGIN
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||
subtitle: 'Suivez la santé de la Carplace, les abonnements, l’activité 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 d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||||
],
|
||||
health: ['Comptes locataires', 'Identité storefront', 'Piste d’audit admin'],
|
||||
health: ['Comptes locataires', 'Identité Carplace', 'Piste d’audit 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 d’accueil principale de la storefront, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
description: 'Modifiez ici la page d’accueil principale de la Carplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
homepageTitle: 'Page d’accueil du site principal',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil storefront affichée sur le site principal de RentalDriveGo.',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil Carplace affichée sur le site principal de RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la page d’accueil',
|
||||
savingHomepage: 'Enregistrement…',
|
||||
homepageSaved: 'Page d’accueil storefront enregistrée.',
|
||||
homepageSaved: 'Page d’accueil 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 l’enregistrement.',
|
||||
homepageSections: 'Sections de la page d’accueil',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil storefront principale.',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil 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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 company’s 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 company’s 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 l’essai 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 d’entreprise.",
|
||||
"renterBody": "La storefront sert de moteur de découverte, pas d’agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"renterBody": "La Carplace sert de moteur de découverte, pas d’agré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 d’administration vers la découverte storefront et les pages de réservation de marque."
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux d’administration 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,7 +170,7 @@
|
||||
"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"
|
||||
],
|
||||
@@ -185,7 +185,7 @@
|
||||
{
|
||||
"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."
|
||||
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la Carplace."
|
||||
},
|
||||
{
|
||||
"number": "3",
|
||||
@@ -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 d’accueil 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 d’accueil 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 l’espace"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() } } }
|
||||
@@ -78,13 +100,23 @@ export async function searchVehicles(params: {
|
||||
startDate?: string; endDate?: string; category?: string
|
||||
maxPrice?: number; transmission?: string; make?: string; model?: string
|
||||
page?: number; pageSize?: number
|
||||
}) {
|
||||
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.maxPrice !== undefined) where.dailyRate = { lte: params.maxPrice }
|
||||
@@ -92,40 +124,83 @@ export async function searchVehicles(params: {
|
||||
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>()
|
||||
|
||||
let availableVehicles = locationFiltered
|
||||
if (range) {
|
||||
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
|
||||
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
|
||||
}),
|
||||
)
|
||||
const availMap = new Map<string, any>(availability)
|
||||
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,
|
||||
@@ -216,9 +356,18 @@ export async function createStorefrontReservation(body: {
|
||||
})
|
||||
|
||||
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||
const totalAmount = vehicle.dailyRate * totalDays
|
||||
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 d’assurance', 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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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' }),
|
||||
]))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
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 })))
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import CarplaceFooter from '@/components/carplace/CarplaceFooter'
|
||||
import CarplaceHeader from '@/components/carplace/CarplaceHeader'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
const skipLabels = { en: 'Skip to content', fr: 'Aller au contenu', ar: 'الانتقال إلى المحتوى' } as const
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useCarplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col">
|
||||
<a href="#main-content" className="skip-link">{skipLabels[language]}</a>
|
||||
<CarplaceHeader dashboardUrl={dashboardUrl} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<CarplaceFooter dashboardUrl={dashboardUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ArrowRight, BadgeCheck, Building2, CalendarCheck2, CarFront, CircleDollarSign, FileCheck2, MapPin, SearchCheck, ShieldCheck, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import CarplaceSearchForm from '@/components/carplace/CarplaceSearchForm'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { formatOfferValue } from '@/lib/carplace/offers'
|
||||
import { carplaceCompanyHref, carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CarplaceHomeResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
ECONOMY: CircleDollarSign,
|
||||
COMPACT: CarFront,
|
||||
MIDSIZE: CarFront,
|
||||
FULLSIZE: CarFront,
|
||||
SUV: ShieldCheck,
|
||||
LUXURY: Sparkles,
|
||||
VAN: Building2,
|
||||
TRUCK: Building2,
|
||||
} as const
|
||||
|
||||
export default async function CarplaceHomePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const fallback: CarplaceHomeResponse = { cities: [], offers: [], vehicles: [], companies: [] }
|
||||
const home = await carplaceFetchOrDefault<CarplaceHomeResponse>('/carplace/home', fallback)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="carplace-hero site-glow">
|
||||
<div className="shell grid gap-10 py-12 lg:grid-cols-[1.05fr_.95fr] lg:items-center lg:py-20">
|
||||
<div className="max-w-3xl">
|
||||
<p className="site-kicker">{copy.hero.eyebrow}</p>
|
||||
<h1 className="mt-5 text-4xl font-black leading-[1.03] tracking-[-0.045em] text-blue-950 dark:text-white sm:text-6xl">{copy.hero.title}</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg">{copy.hero.body}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3 text-sm font-semibold text-stone-700 dark:text-slate-200">
|
||||
<span className="carplace-trust-pill"><BadgeCheck className="h-4 w-4 text-emerald-600" />{copy.trust[0][0]}</span>
|
||||
<span className="carplace-trust-pill"><CalendarCheck2 className="h-4 w-4 text-blue-700 dark:text-blue-300" />{copy.trust[1][0]}</span>
|
||||
<span className="carplace-trust-pill"><FileCheck2 className="h-4 w-4 text-orange-600" />{copy.trust[2][0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-5 -z-10 rounded-[3rem] bg-gradient-to-br from-orange-300/30 via-transparent to-blue-400/25 blur-2xl" />
|
||||
<CarplaceSearchForm cities={home.cities} copy={copy} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-y border-stone-200 bg-white/80 dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<div className="shell grid gap-px py-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{copy.trust.map(([title, body], index) => {
|
||||
const Icon = [Building2, SearchCheck, BadgeCheck, CircleDollarSign][index] ?? ShieldCheck
|
||||
return <div key={title} className="flex gap-3 px-4 py-4"><span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-blue-50 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200"><Icon className="h-5 w-5" /></span><div><h2 className="text-sm font-black text-blue-950 dark:text-white">{title}</h2><p className="mt-1 text-xs leading-5 text-stone-500 dark:text-slate-400">{body}</p></div></div>
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.cities} />
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{home.cities.length > 0 ? home.cities.slice(0, 16).map((city) => (
|
||||
<Link key={city} href={`${carplaceHref('/search')}?pickupLocation=${encodeURIComponent(city)}`} className="carplace-city-link"><MapPin className="h-4 w-4" />{city}</Link>
|
||||
)) : <p className="text-sm text-stone-500 dark:text-slate-400">{copy.status.unavailableBody}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section pt-0">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.categories} />
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Object.entries(copy.categories).map(([value, label]) => {
|
||||
const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS] ?? CarFront
|
||||
return (
|
||||
<Link key={value} href={`${carplaceHref('/search')}?category=${value}`} className="carplace-category-card">
|
||||
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-blue-50 text-blue-900 dark:bg-blue-900/50 dark:text-blue-100"><Icon className="h-6 w-6" /></span>
|
||||
<span className="mt-5 flex items-center justify-between gap-3 text-lg font-black text-blue-950 dark:text-white">{label}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{home.offers.length > 0 ? (
|
||||
<section id="offers" className="site-section scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.deals} title={copy.sections.offers} />
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{home.offers.slice(0, 6).map((offer) => {
|
||||
const slug = offer.company.brand?.subdomain || offer.company.slug || ''
|
||||
return (
|
||||
<article key={offer.id} className="carplace-offer-card">
|
||||
<div className="flex items-start justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.16em] text-orange-200">{offer.company.brand?.displayName ?? copy.vehicle.company}</p><h3 className="mt-2 text-xl font-black text-white">{offer.title}</h3></div><span className="rounded-2xl bg-white px-3 py-2 text-2xl font-black text-blue-950">{formatOfferValue(offer, language)}</span></div>
|
||||
<p className="mt-6 text-sm text-blue-100">{new Intl.DateTimeFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-MA' : 'en-MA', { dateStyle: 'medium' }).format(new Date(offer.validUntil))}</p>
|
||||
{slug ? <Link href={carplaceCompanyHref(slug)} className="mt-5 inline-flex items-center gap-2 text-sm font-bold text-white no-underline">{copy.actions.viewFleet}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></Link> : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="site-section">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.vehicles} />
|
||||
<Link href={carplaceHref('/search')} className="carplace-secondary-button">{copy.actions.search}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></Link>
|
||||
</div>
|
||||
{home.vehicles.length > 0 ? <div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{home.vehicles.slice(0, 9).map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} />)}</div> : <EmptyState copy={copy.status.unavailableBody} />}
|
||||
</section>
|
||||
|
||||
<section id="companies" className="site-section scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.companies} title={copy.sections.companies} />
|
||||
{home.companies.length > 0 ? (
|
||||
<div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{home.companies.slice(0, 8).map((company) => {
|
||||
const slug = company.brand?.subdomain || company.slug || ''
|
||||
return (
|
||||
<Link key={company.id} href={carplaceCompanyHref(slug)} className="carplace-company-card">
|
||||
<div className="flex items-start justify-between gap-3"><span className="grid h-12 w-12 place-items-center rounded-2xl bg-blue-50 text-blue-900 dark:bg-blue-900/50 dark:text-blue-100"><Building2 className="h-6 w-6" /></span>{company.brand?.carplaceRating ? <span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950/50 dark:text-amber-200">★ {company.brand.carplaceRating.toFixed(1)}</span> : null}</div>
|
||||
<h3 className="mt-5 truncate text-lg font-black text-blue-950 dark:text-white">{company.brand?.displayName || company.name || copy.vehicle.company}</h3>
|
||||
<p className="mt-2 flex items-center gap-2 text-sm text-stone-500 dark:text-slate-400"><MapPin className="h-4 w-4" />{company.brand?.publicCity || copy.company.partner}</p>
|
||||
<p className="mt-4 text-sm font-semibold text-stone-700 dark:text-slate-200">{company._count.vehicles} {copy.company.vehicles}</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : <EmptyState copy={copy.status.unavailableBody} />}
|
||||
</section>
|
||||
|
||||
<section id="how-it-works" className="site-section scroll-mt-24">
|
||||
<div className="rounded-[2.5rem] bg-blue-950 p-7 text-white sm:p-10 lg:p-14">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.how} inverse />
|
||||
<div className="mt-9 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{copy.how.map(([title, body], index) => <article key={title} className="relative rounded-2xl border border-blue-800 bg-blue-900/55 p-5"><span className="grid h-9 w-9 place-items-center rounded-full bg-orange-500 text-sm font-black text-white">{index + 1}</span><h3 className="mt-5 text-lg font-black">{title}</h3><p className="mt-2 text-sm leading-6 text-blue-100">{body}</p></article>)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section grid gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.guidance} />
|
||||
<div className="mt-6 grid gap-4">{copy.guidance.map(([title, body]) => <article key={title} className="carplace-guidance"><ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" /><div><h3 className="font-black text-blue-950 dark:text-white">{title}</h3><p className="mt-1 text-sm leading-6 text-stone-600 dark:text-slate-300">{body}</p></div></article>)}</div>
|
||||
</div>
|
||||
<div id="help" className="scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.help} title={copy.sections.faq} />
|
||||
<div className="mt-6 grid gap-3">{copy.faq.map(([question, answer]) => <details key={question} className="carplace-faq"><summary>{question}</summary><p>{answer}</p></details>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading({ eyebrow, title, inverse = false }: { eyebrow: string; title: string; inverse?: boolean }) {
|
||||
return <div><p className={`text-xs font-bold uppercase tracking-[0.2em] ${inverse ? 'text-orange-300' : 'text-orange-600 dark:text-orange-400'}`}>{eyebrow}</p><h2 className={`mt-3 text-3xl font-black tracking-[-0.035em] sm:text-4xl ${inverse ? 'text-white' : 'text-blue-950 dark:text-white'}`}>{title}</h2></div>
|
||||
}
|
||||
|
||||
function EmptyState({ copy }: { copy: string }) {
|
||||
return <div className="mt-6 rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500 dark:border-blue-800 dark:text-slate-400">{copy}</div>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import RequestSuccessClient from '@/components/carplace/RequestSuccessClient'
|
||||
|
||||
export default async function RequestSuccessPage({ params }: { params: Promise<{ reference: string }> }) {
|
||||
const { reference } = await params
|
||||
return <RequestSuccessClient reference={decodeURIComponent(reference)} />
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { SlidersHorizontal, SearchX } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import CarplaceSearchForm, { type CarplaceSearchValues } from '@/components/carplace/CarplaceSearchForm'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { VehicleSearchResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export default async function CarplaceSearchPage({ searchParams }: { searchParams?: Promise<RawSearchParams> }) {
|
||||
const params = (await searchParams) ?? {}
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const value = (key: string) => typeof params[key] === 'string' ? params[key] as string : ''
|
||||
|
||||
const initial: CarplaceSearchValues = {
|
||||
pickupLocation: value('pickupLocation') || value('city'),
|
||||
dropoffLocation: value('dropoffLocation'),
|
||||
dropoffMode: value('dropoffMode') === 'different' ? 'different' : 'same',
|
||||
pickupDate: value('pickupDate'),
|
||||
pickupTime: value('pickupTime') || '10:00',
|
||||
returnDate: value('returnDate'),
|
||||
returnTime: value('returnTime') || '10:00',
|
||||
driverAge: value('driverAge') || '25',
|
||||
promoCode: value('promoCode'),
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(value('page') || '1') || 1)
|
||||
const pageSize = 18
|
||||
const apiQuery = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (initial.pickupLocation) { apiQuery.set('city', initial.pickupLocation); apiQuery.set('pickupLocation', initial.pickupLocation) }
|
||||
if (initial.dropoffMode) apiQuery.set('dropoffMode', initial.dropoffMode)
|
||||
if (initial.dropoffMode === 'different' && initial.dropoffLocation) apiQuery.set('dropoffLocation', initial.dropoffLocation)
|
||||
const startDate = toIso(initial.pickupDate, initial.pickupTime)
|
||||
const endDate = toIso(initial.returnDate, initial.returnTime)
|
||||
if (startDate) apiQuery.set('startDate', startDate)
|
||||
if (endDate) apiQuery.set('endDate', endDate)
|
||||
for (const key of ['category', 'transmission', 'make', 'model'] as const) if (value(key)) apiQuery.set(key, value(key))
|
||||
const maxPriceMad = Number(value('maxPrice'))
|
||||
if (Number.isFinite(maxPriceMad) && maxPriceMad > 0) apiQuery.set('maxPrice', String(Math.round(maxPriceMad * 100)))
|
||||
|
||||
const fallback: VehicleSearchResponse = { items: [], pagination: { page, pageSize, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } }
|
||||
const [cities, response] = await Promise.all([
|
||||
carplaceFetchOrDefault<string[]>('/carplace/cities', []),
|
||||
carplaceFetchOrDefault<VehicleSearchResponse>(`/carplace/search?${apiQuery.toString()}`, fallback),
|
||||
])
|
||||
|
||||
const sort = value('sort') || 'price-asc'
|
||||
const items = [...response.items].sort((a, b) => sort === 'price-desc' ? b.dailyRate - a.dailyRate : a.dailyRate - b.dailyRate)
|
||||
const cardQuery = buildCardQuery(params)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="border-b border-stone-200 bg-white/85 py-6 dark:border-blue-900 dark:bg-blue-950/80">
|
||||
<div className="shell"><CarplaceSearchForm cities={cities} copy={copy} initial={initial} compact /></div>
|
||||
</section>
|
||||
|
||||
<div className="shell grid gap-8 py-10 lg:grid-cols-[270px_1fr]">
|
||||
<aside className="h-fit rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950 lg:sticky lg:top-24">
|
||||
<h2 className="flex items-center gap-2 text-lg font-black text-blue-950 dark:text-white"><SlidersHorizontal className="h-5 w-5" />{copy.sections.filters}</h2>
|
||||
<form action={carplaceHref('/search')} method="get" className="mt-5 grid gap-4">
|
||||
{preservedTripInputs(params)}
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.vehicle.category}<select name="category" defaultValue={value('category')} className="carplace-input"><option value="">{copy.results.allCategories}</option>{Object.entries(copy.categories).map(([category, label]) => <option key={category} value={category}>{label}</option>)}</select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.maxPrice}<input type="number" min="0" name="maxPrice" defaultValue={value('maxPrice')} className="carplace-input" inputMode="numeric" /></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.transmission}<select name="transmission" defaultValue={value('transmission')} className="carplace-input"><option value="">—</option>{response.facets.transmissions.map((facet) => <option key={facet.value} value={facet.value}>{facet.value} ({facet.count})</option>)}</select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.make}<input name="make" defaultValue={value('make')} className="carplace-input" list="carplace-makes" /><datalist id="carplace-makes">{response.facets.makes.map((facet) => <option key={facet.value} value={facet.value} />)}</datalist></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.model}<input name="model" defaultValue={value('model')} className="carplace-input" /></label>
|
||||
<button className="carplace-primary-button justify-center" type="submit">{copy.results.apply}</button>
|
||||
<Link href={carplaceHref('/search')} className="carplace-secondary-button justify-center">{copy.results.clear}</Link>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">Carplace</p><h1 className="mt-2 text-3xl font-black tracking-[-0.035em] text-blue-950 dark:text-white">{copy.results.title}</h1><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{response.pagination.totalItems} {copy.results.count}</p></div>
|
||||
<form action={carplaceHref('/search')} method="get" className="flex items-end gap-2">
|
||||
{Object.entries(params).flatMap(([key, raw]) => key === 'sort' || key === 'page' ? [] : (Array.isArray(raw) ? raw : [raw]).filter((entry): entry is string => typeof entry === 'string').map((entry) => <input key={`${key}-${entry}`} type="hidden" name={key} value={entry} />))}
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.sort}<select name="sort" defaultValue={sort} className="carplace-input"><option value="price-asc">{copy.results.sortPriceAsc}</option><option value="price-desc">{copy.results.sortPriceDesc}</option></select></label>
|
||||
<button type="submit" className="carplace-secondary-button">{copy.results.apply}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? <div className="mt-7 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{items.map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} query={cardQuery} />)}</div> : <div className="mt-8 rounded-[2rem] border border-dashed border-stone-300 p-10 text-center dark:border-blue-800"><SearchX className="mx-auto h-10 w-10 text-stone-400" /><h2 className="mt-4 text-xl font-black text-blue-950 dark:text-white">{copy.results.none}</h2><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{copy.results.noneBody}</p></div>}
|
||||
|
||||
{response.pagination.totalPages > 1 ? <Pagination current={response.pagination.page} total={response.pagination.totalPages} params={params} previous={copy.results.previous} next={copy.results.next} label={copy.accessibility.pagination} /> : null}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function toIso(date?: string, time?: string): string | null { if (!date || !time) return null; const result = new Date(`${date}T${time}:00`); return Number.isNaN(result.getTime()) ? null : result.toISOString() }
|
||||
|
||||
function buildCardQuery(params: RawSearchParams): URLSearchParams {
|
||||
const query = new URLSearchParams()
|
||||
for (const key of ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode'] as const) {
|
||||
const raw = params[key]
|
||||
if (typeof raw === 'string' && raw) query.set(key, raw)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function preservedTripInputs(params: RawSearchParams) {
|
||||
return ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode'].flatMap((key) => {
|
||||
const raw = params[key]
|
||||
return (Array.isArray(raw) ? raw : [raw]).filter((entry): entry is string => typeof entry === 'string').map((entry) => <input key={`${key}-${entry}`} type="hidden" name={key} value={entry} />)
|
||||
})
|
||||
}
|
||||
|
||||
function Pagination({ current, total, params, previous, next, label }: { current: number; total: number; params: RawSearchParams; previous: string; next: string; label: string }) {
|
||||
const href = (page: number) => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, raw]) => { if (key === 'page') return; (Array.isArray(raw) ? raw : [raw]).forEach((entry) => { if (typeof entry === 'string') query.append(key, entry) }) }); query.set('page', String(page)); return `${carplaceHref('/search')}?${query.toString()}` }
|
||||
return <nav className="mt-10 flex items-center justify-center gap-3" aria-label={label}>{current > 1 ? <Link href={href(current - 1)} className="carplace-secondary-button">{previous}</Link> : null}<span className="text-sm font-bold text-stone-600 dark:text-slate-300">{current} / {total}</span>{current < total ? <Link href={href(current + 1)} className="carplace-secondary-button">{next}</Link> : null}</nav>
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #fafafa 28%, #f5f5f5 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
.site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
html.dark .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(30, 64, 175, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
.site-section {
|
||||
@apply shell py-10 sm:py-14 lg:py-16;
|
||||
}
|
||||
|
||||
.site-panel {
|
||||
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .site-panel {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-image: linear-gradient(160deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-image: linear-gradient(160deg, #172554 0%, #1b3068 100%);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
|
||||
border-color: rgb(20 45 95 / 0.8);
|
||||
background-color: #172554;
|
||||
}
|
||||
|
||||
html.dark .site-panel-contrast {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
|
||||
}
|
||||
|
||||
.site-lead {
|
||||
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
|
||||
}
|
||||
|
||||
.site-link-primary {
|
||||
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.site-link-secondary {
|
||||
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-[#2e50f1] dark:bg-[#2e50f1]/30 dark:text-[#1e3de0] dark:hover:border-[#1e3de0] dark:hover:text-white;
|
||||
}
|
||||
|
||||
/* Carplace public experience */
|
||||
html {
|
||||
min-width: 320px;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 96px;
|
||||
}
|
||||
|
||||
html[lang='ar'] body {
|
||||
font-family: 'Noto Sans Arabic', Tahoma, Arial, sans-serif;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset-block-start: .5rem;
|
||||
inset-inline-start: .5rem;
|
||||
transform: translateY(-160%);
|
||||
border-radius: .75rem;
|
||||
background: #172554;
|
||||
color: white;
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 800;
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.carplace-header {
|
||||
position: sticky;
|
||||
z-index: 100;
|
||||
inset-block-start: 0;
|
||||
border-bottom: 1px solid rgb(231 229 228 / .9);
|
||||
background: rgb(255 255 255 / .94);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / .06);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
html.dark .carplace-header {
|
||||
border-color: rgb(30 64 175 / .55);
|
||||
background: rgb(23 37 84 / .94);
|
||||
}
|
||||
|
||||
.carplace-control,
|
||||
.carplace-icon-button,
|
||||
.carplace-company-link,
|
||||
.carplace-primary-button,
|
||||
.carplace-secondary-button,
|
||||
.carplace-light-button,
|
||||
.carplace-orange-button {
|
||||
@apply inline-flex min-h-11 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-bold no-underline transition disabled:cursor-not-allowed disabled:opacity-60;
|
||||
}
|
||||
|
||||
.carplace-control,
|
||||
.carplace-icon-button,
|
||||
.carplace-secondary-button {
|
||||
@apply border border-stone-300 bg-white text-stone-700 hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-slate-200 dark:hover:border-blue-500 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.carplace-icon-button { @apply min-w-11 justify-center px-0; }
|
||||
.carplace-company-link { @apply bg-blue-950 text-white hover:bg-blue-900 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-primary-button,
|
||||
.carplace-orange-button { @apply bg-orange-600 text-white hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-light-button { @apply bg-white text-blue-950 hover:bg-blue-50; }
|
||||
|
||||
.carplace-menu {
|
||||
@apply absolute top-full z-50 mt-2 w-60 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1 shadow-2xl dark:border-blue-800 dark:bg-blue-950;
|
||||
}
|
||||
|
||||
.carplace-menu-item { @apply flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-start text-sm font-semibold text-stone-700 hover:bg-blue-50 dark:text-slate-200 dark:hover:bg-blue-900/50; }
|
||||
|
||||
.carplace-hero {
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 10%, rgb(249 115 22 / .16), transparent 28%),
|
||||
radial-gradient(circle at 92% 8%, rgb(37 99 235 / .14), transparent 28%),
|
||||
linear-gradient(180deg, #fff, #f8fafc);
|
||||
}
|
||||
|
||||
html.dark .carplace-hero {
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 10%, rgb(249 115 22 / .18), transparent 28%),
|
||||
radial-gradient(circle at 92% 8%, rgb(59 130 246 / .16), transparent 28%),
|
||||
linear-gradient(180deg, #172554, #0f1a40);
|
||||
}
|
||||
|
||||
.carplace-search {
|
||||
@apply rounded-[2rem] border border-stone-200 bg-white/95 p-5 shadow-[0_30px_90px_rgba(15,23,42,0.14)] backdrop-blur dark:border-blue-800 dark:bg-blue-950/95 sm:p-6;
|
||||
}
|
||||
|
||||
.carplace-search-compact {
|
||||
@apply rounded-2xl p-4 shadow-none;
|
||||
}
|
||||
|
||||
.carplace-input {
|
||||
@apply min-h-11 w-full rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm font-medium text-blue-950 outline-none transition placeholder:text-stone-400 hover:border-blue-500 focus:border-blue-700 focus:ring-2 focus:ring-blue-200 dark:border-blue-800 dark:bg-blue-950 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-900;
|
||||
}
|
||||
|
||||
.carplace-choice { @apply cursor-pointer rounded-full border border-stone-300 bg-white px-3 py-2 text-xs font-bold text-stone-600 transition dark:border-blue-800 dark:bg-blue-950 dark:text-slate-300; }
|
||||
.carplace-choice-active { @apply border-orange-500 bg-orange-50 text-orange-800 dark:bg-orange-950/40 dark:text-orange-200; }
|
||||
.carplace-trust-pill { @apply inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white/85 px-3 py-2 shadow-sm dark:border-blue-800 dark:bg-blue-950/70; }
|
||||
.carplace-dark-pill { @apply inline-flex items-center gap-2 rounded-full border border-blue-700 bg-blue-900/70 px-3 py-2; }
|
||||
.carplace-spec { @apply inline-flex items-center gap-1.5 rounded-full bg-stone-100 px-3 py-1.5 dark:bg-blue-900/45; }
|
||||
|
||||
.carplace-city-link { @apply inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-4 py-2.5 text-sm font-bold text-stone-700 no-underline shadow-sm transition hover:-translate-y-0.5 hover:border-orange-400 hover:text-orange-700 dark:border-blue-800 dark:bg-blue-950 dark:text-slate-200 dark:hover:border-orange-500 dark:hover:text-orange-300; }
|
||||
.carplace-category-card,
|
||||
.carplace-company-card { @apply rounded-[1.5rem] border border-stone-200 bg-white p-5 no-underline shadow-[0_14px_45px_rgba(15,23,42,0.06)] transition hover:-translate-y-1 hover:border-blue-300 hover:shadow-[0_20px_60px_rgba(15,23,42,0.12)] dark:border-blue-900 dark:bg-blue-950 dark:hover:border-blue-600; }
|
||||
.carplace-offer-card { @apply rounded-[2rem] bg-gradient-to-br from-blue-950 to-blue-800 p-6 shadow-[0_24px_70px_rgba(15,23,42,0.2)]; }
|
||||
.carplace-guidance { @apply flex gap-3 rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950; }
|
||||
.carplace-faq { @apply rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950; }
|
||||
.carplace-faq summary { @apply cursor-pointer font-black text-blue-950 dark:text-white; }
|
||||
.carplace-faq p { @apply mb-0 mt-3 text-sm leading-7 text-stone-600 dark:text-slate-300; }
|
||||
|
||||
.carplace-vehicle-card { @apply flex min-w-0 flex-col overflow-hidden rounded-[1.75rem] border border-stone-200 bg-white shadow-[0_18px_60px_rgba(15,23,42,0.08)] transition hover:-translate-y-1 hover:border-blue-300 hover:shadow-[0_24px_70px_rgba(15,23,42,0.14)] dark:border-blue-900 dark:bg-blue-950 dark:hover:border-blue-600; }
|
||||
.carplace-card-action { @apply grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-blue-950 text-white no-underline transition hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-booking { @apply rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_70px_rgba(15,23,42,0.12)] dark:border-blue-800 dark:bg-blue-950; }
|
||||
.carplace-step { @apply flex min-w-0 flex-col items-center gap-1 text-center text-[10px] font-bold text-stone-400; }
|
||||
.carplace-step span { @apply grid h-7 w-7 place-items-center rounded-full border border-stone-300 bg-white dark:border-blue-800 dark:bg-blue-950; }
|
||||
.carplace-step-active { @apply text-orange-700 dark:text-orange-300; }
|
||||
.carplace-step-active span { @apply border-orange-500 bg-orange-50 dark:bg-orange-950/50; }
|
||||
.carplace-step-done { @apply text-emerald-700 dark:text-emerald-300; }
|
||||
.carplace-step-done span { @apply border-emerald-500 bg-emerald-50 dark:bg-emerald-950/50; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import CarplaceShell from '@/components/CarplaceShell'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
return {
|
||||
title: `${copy.brand} by RentalDriveGo`,
|
||||
description: copy.hero.body,
|
||||
alternates: { canonical: '/carplace' },
|
||||
openGraph: {
|
||||
title: `${copy.brand} by RentalDriveGo`,
|
||||
description: copy.hero.body,
|
||||
type: 'website',
|
||||
locale: language === 'ar' ? 'ar_MA' : language === 'fr' ? 'fr_MA' : 'en_MA',
|
||||
siteName: 'RentalDriveGo',
|
||||
},
|
||||
icons: {
|
||||
icon: '/rentaldrivego.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentaldrivego.png',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):localStorage.getItem('rentaldrivego-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<CarplaceShell initialLanguage={language} initialTheme={theme}>{children}</CarplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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', () => {
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { CarplaceLanguage } from '@/lib/i18n'
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
brand: 'Carplace',
|
||||
tagline: 'Rental cars from trusted local companies',
|
||||
accessibility: { home: 'RentalDriveGo home', menu: 'Menu', mobileNav: 'Carplace mobile navigation', pagination: 'Search result pages' },
|
||||
nav: { find: 'Find a car', companies: 'Rental companies', deals: 'Deals', how: 'How it works', help: 'Help' },
|
||||
actions: { companySpace: 'Company space', search: 'Search cars', editSearch: 'Edit search', viewVehicle: 'View vehicle', viewFleet: 'View fleet', request: 'Request this vehicle', back: 'Back', continue: 'Continue', submit: 'Send reservation request', retry: 'Try again' },
|
||||
hero: { eyebrow: 'RentalDriveGo Carplace', title: 'Find the right rental car with clear information.', body: 'Compare vehicles from local rental companies, check availability for your dates, and send a clear reservation request. The company confirms directly with you.' },
|
||||
search: { title: 'Plan your rental', pickup: 'Pick-up location', returnLocation: 'Return location', sameReturn: 'Return to the same location', differentReturn: 'Return somewhere else', pickupDate: 'Pick-up date', pickupTime: 'Pick-up time', returnDate: 'Return date', returnTime: 'Return time', age: 'Driver age', promo: 'Promo code', optional: 'Optional', locationPlaceholder: 'Choose a city', invalidDates: 'Return must be after pick-up.' },
|
||||
trust: [
|
||||
['Local inventory', 'Browse vehicles published by active rental companies.'],
|
||||
['Date-specific checks', 'Availability is checked for the dates you select.'],
|
||||
['Direct confirmation', 'Your request remains pending until the company confirms it.'],
|
||||
['Clear estimates', 'Displayed totals are estimates and require final company confirmation.'],
|
||||
],
|
||||
sections: { cities: 'Browse by city', categories: 'Choose a vehicle type', offers: 'Current deals', vehicles: 'Recommended vehicles', companies: 'Rental companies', how: 'How Carplace works', guidance: 'Before you request', faq: 'Common questions', results: 'Available vehicles', filters: 'Filter results' },
|
||||
categories: { ECONOMY: 'Economy', COMPACT: 'Compact', MIDSIZE: 'Midsize', FULLSIZE: 'Full-size', SUV: 'SUV', LUXURY: 'Luxury', VAN: 'Van', TRUCK: 'Truck' },
|
||||
how: [
|
||||
['Search', 'Choose a location and rental dates.'],
|
||||
['Compare', 'Review vehicles, companies, prices, and important conditions.'],
|
||||
['Request', 'Send your contact details and trip information securely.'],
|
||||
['Confirm', 'The rental company reviews the request and contacts you.'],
|
||||
],
|
||||
guidance: [
|
||||
['Bring valid documents', 'The company may request identification and a valid driving licence before pickup.'],
|
||||
['Expect a deposit', 'Deposit, insurance, mileage, and delivery conditions are set by the rental company.'],
|
||||
['Review final terms', 'The company must confirm availability and the final amount before the rental is accepted.'],
|
||||
],
|
||||
faq: [
|
||||
['Is my reservation confirmed immediately?', 'No. Carplace submits a request. The rental company confirms availability and final terms directly with you.'],
|
||||
['Is the displayed amount the final price?', 'It is an estimated rental subtotal. Taxes, deposit, insurance, delivery, and company charges may apply.'],
|
||||
['Do I need a renter account?', 'No. Carplace currently supports a guest reservation-request flow.'],
|
||||
],
|
||||
vehicle: { from: 'From', perDay: '/ day', estimated: 'Estimated rental subtotal', estimateDisclaimer: 'Final price, deposit, insurance, taxes, mileage, and delivery terms require company confirmation.', available: 'Available for selected dates', unavailable: 'Unavailable for selected dates', availableFrom: 'Next available', company: 'Rental company', rating: 'Rating', new: 'New listing', seats: 'Seats', transmission: 'Transmission', fuel: 'Fuel', category: 'Category', year: 'Year', features: 'Included features', pickupLocations: 'Pick-up locations', noPhoto: 'Vehicle photo unavailable', directContact: 'Direct company contact', phone: 'Call company', whatsapp: 'WhatsApp company', similar: 'Similar vehicles' },
|
||||
results: { title: 'Cars matching your trip', count: 'vehicles found', none: 'No vehicles match this search yet.', noneBody: 'Try another city, broader category, or different dates.', error: 'Search results are temporarily unavailable.', sort: 'Sort', sortPriceAsc: 'Lowest daily price', sortPriceDesc: 'Highest daily price', allCategories: 'All categories', maxPrice: 'Maximum daily rate', transmission: 'Transmission', make: 'Make', model: 'Model', apply: 'Apply filters', clear: 'Clear filters', previous: 'Previous', next: 'Next' },
|
||||
company: { partner: 'Carplace rental company', fleet: 'Available fleet', offers: 'Current offers', contact: 'Contact company', policies: 'Rental information', noOffers: 'No active public offers.', vehicles: 'published vehicles', reviews: 'reviews' },
|
||||
booking: { title: 'Reservation request', stepTrip: 'Trip', stepContact: 'Contact', stepReview: 'Review', contactIntro: 'The company uses these details to confirm the request.', firstName: 'First name', lastName: 'Last name', email: 'Email', phone: 'Phone', notes: 'Notes for the company', notesPlaceholder: 'Flight number, delivery request, or another useful detail', reviewTitle: 'Review your request', pendingNotice: 'Submitting this form does not confirm the booking. The company will review availability and contact you.', consent: 'I agree that my contact and trip details may be shared with the selected rental company for this request.', successTitle: 'Your request was sent', successBody: 'The rental company will review it and contact you. Do not consider the vehicle confirmed until the company accepts the request.', reference: 'Request reference', status: 'Status', pending: 'Pending company confirmation', error: 'The request could not be submitted.', unavailable: 'The vehicle is no longer available for these dates.', privacy: 'Privacy policy', terms: 'Terms' },
|
||||
footer: { description: 'A renter-first car rental platform by RentalDriveGo. Search locally, request clearly, and receive confirmation directly from the rental company.', explore: 'Explore', support: 'Support', business: 'For rental companies', rights: 'All rights reserved.', language: 'Language', theme: 'Theme', light: 'Light', dark: 'Dark' },
|
||||
status: { unavailable: 'Carplace unavailable', unavailableBody: 'Carplace data is temporarily unavailable. Please try again shortly.' },
|
||||
},
|
||||
fr: {
|
||||
brand: 'Carplace',
|
||||
tagline: 'Voitures de location auprès de sociétés locales fiables',
|
||||
accessibility: { home: 'Accueil RentalDriveGo', menu: 'Menu', mobileNav: 'Navigation mobile Carplace', pagination: 'Pages de résultats' },
|
||||
nav: { find: 'Trouver une voiture', companies: 'Sociétés de location', deals: 'Offres', how: 'Fonctionnement', help: 'Aide' },
|
||||
actions: { companySpace: 'Espace entreprise', search: 'Rechercher', editSearch: 'Modifier la recherche', viewVehicle: 'Voir le véhicule', viewFleet: 'Voir la flotte', request: 'Demander ce véhicule', back: 'Retour', continue: 'Continuer', submit: 'Envoyer la demande', retry: 'Réessayer' },
|
||||
hero: { eyebrow: 'RentalDriveGo Carplace', title: 'Trouvez la bonne voiture de location sans ambiguïté inutile.', body: 'Comparez les véhicules de sociétés locales, vérifiez la disponibilité pour vos dates et envoyez une demande claire. La société vous confirme directement.' },
|
||||
search: { title: 'Planifiez votre location', pickup: 'Lieu de prise en charge', returnLocation: 'Lieu de retour', sameReturn: 'Retour au même endroit', differentReturn: 'Retour à un autre endroit', pickupDate: 'Date de départ', pickupTime: 'Heure de départ', returnDate: 'Date de retour', returnTime: 'Heure de retour', age: 'Âge du conducteur', promo: 'Code promotionnel', optional: 'Facultatif', locationPlaceholder: 'Choisir une ville', invalidDates: 'Le retour doit être postérieur au départ.' },
|
||||
trust: [
|
||||
['Inventaire local', 'Parcourez les véhicules publiés par des sociétés de location actives.'],
|
||||
['Vérification par dates', 'La disponibilité est vérifiée pour les dates sélectionnées.'],
|
||||
['Confirmation directe', "La demande reste en attente jusqu'à la confirmation de la société."],
|
||||
['Estimations claires', 'Les montants affichés sont des estimations, pas des garanties inventées.'],
|
||||
],
|
||||
sections: { cities: 'Parcourir par ville', categories: 'Choisir un type de véhicule', offers: 'Offres actuelles', vehicles: 'Véhicules recommandés', companies: 'Sociétés de location', how: 'Comment fonctionne Carplace', guidance: 'Avant votre demande', faq: 'Questions fréquentes', results: 'Véhicules disponibles', filters: 'Filtrer les résultats' },
|
||||
categories: { ECONOMY: 'Économie', COMPACT: 'Compacte', MIDSIZE: 'Intermédiaire', FULLSIZE: 'Grande berline', SUV: 'SUV', LUXURY: 'Luxe', VAN: 'Van', TRUCK: 'Camion' },
|
||||
how: [
|
||||
['Recherchez', 'Choisissez un lieu et les dates de location.'],
|
||||
['Comparez', 'Examinez les véhicules, sociétés, prix et conditions importantes.'],
|
||||
['Demandez', 'Envoyez vos coordonnées et les informations du trajet.'],
|
||||
['Confirmez', 'La société étudie la demande et vous contacte.'],
|
||||
],
|
||||
guidance: [
|
||||
['Préparez vos documents', "La société peut demander une pièce d'identité et un permis valide avant la prise en charge."],
|
||||
['Prévoyez une caution', 'La caution, l’assurance, le kilométrage et la livraison sont fixés par la société.'],
|
||||
['Vérifiez les conditions finales', 'La société doit confirmer la disponibilité et le montant final avant acceptation.'],
|
||||
],
|
||||
faq: [
|
||||
['Ma réservation est-elle confirmée immédiatement ?', 'Non. Carplace envoie une demande. La société confirme directement la disponibilité et les conditions finales.'],
|
||||
['Le montant affiché est-il définitif ?', 'Il s’agit d’un sous-total estimé. Des taxes, une caution, une assurance, la livraison ou d’autres frais peuvent s’appliquer.'],
|
||||
["Ai-je besoin d'un compte client ?", 'Non. Carplace propose actuellement un parcours de demande en tant qu’invité.'],
|
||||
],
|
||||
vehicle: { from: 'À partir de', perDay: '/ jour', estimated: 'Sous-total estimé', estimateDisclaimer: 'Le prix final, la caution, l’assurance, les taxes, le kilométrage et la livraison nécessitent la confirmation de la société.', available: 'Disponible pour les dates sélectionnées', unavailable: 'Indisponible pour les dates sélectionnées', availableFrom: 'Prochaine disponibilité', company: 'Société de location', rating: 'Note', new: 'Nouvelle annonce', seats: 'Places', transmission: 'Transmission', fuel: 'Carburant', category: 'Catégorie', year: 'Année', features: 'Équipements inclus', pickupLocations: 'Lieux de prise en charge', noPhoto: 'Photo indisponible', directContact: 'Contact direct avec la société', phone: 'Appeler la société', whatsapp: 'WhatsApp de la société', similar: 'Véhicules similaires' },
|
||||
results: { title: 'Voitures correspondant à votre trajet', count: 'véhicules trouvés', none: 'Aucun véhicule ne correspond encore à cette recherche.', noneBody: 'Essayez une autre ville, une catégorie plus large ou d’autres dates.', error: 'Les résultats sont temporairement indisponibles.', sort: 'Trier', sortPriceAsc: 'Prix journalier croissant', sortPriceDesc: 'Prix journalier décroissant', allCategories: 'Toutes les catégories', maxPrice: 'Tarif journalier maximum', transmission: 'Transmission', make: 'Marque', model: 'Modèle', apply: 'Appliquer les filtres', clear: 'Effacer les filtres', previous: 'Précédent', next: 'Suivant' },
|
||||
company: { partner: 'Société de location Carplace', fleet: 'Flotte disponible', offers: 'Offres actuelles', contact: 'Contacter la société', policies: 'Informations de location', noOffers: 'Aucune offre publique active.', vehicles: 'véhicules publiés', reviews: 'avis' },
|
||||
booking: { title: 'Demande de réservation', stepTrip: 'Trajet', stepContact: 'Contact', stepReview: 'Vérification', contactIntro: 'La société utilise ces coordonnées pour confirmer la demande.', firstName: 'Prénom', lastName: 'Nom', email: 'E-mail', phone: 'Téléphone', notes: 'Note pour la société', notesPlaceholder: 'Numéro de vol, livraison ou autre information utile', reviewTitle: 'Vérifiez votre demande', pendingNotice: 'Ce formulaire ne confirme pas la réservation. La société vérifiera la disponibilité et vous contactera.', consent: 'J’accepte que mes coordonnées et les détails du trajet soient transmis à la société sélectionnée pour cette demande.', successTitle: 'Votre demande a été envoyée', successBody: 'La société va l’examiner et vous contacter. Le véhicule n’est confirmé qu’après acceptation par la société.', reference: 'Référence de la demande', status: 'Statut', pending: 'En attente de confirmation', error: "La demande n'a pas pu être envoyée.", unavailable: "Le véhicule n'est plus disponible pour ces dates.", privacy: 'Politique de confidentialité', terms: 'Conditions' },
|
||||
footer: { description: 'Une place de marché pensée pour les locataires par RentalDriveGo. Recherchez localement, envoyez une demande claire et recevez la confirmation de la société.', explore: 'Explorer', support: 'Assistance', business: 'Pour les sociétés', rights: 'Tous droits réservés.', language: 'Langue', theme: 'Thème', light: 'Clair', dark: 'Sombre' },
|
||||
status: { unavailable: 'Carplace indisponible', unavailableBody: 'Les données de la place de marché sont temporairement indisponibles.' },
|
||||
},
|
||||
ar: {
|
||||
brand: 'Carplace',
|
||||
tagline: 'سيارات للإيجار من شركات محلية موثوقة',
|
||||
accessibility: { home: 'الصفحة الرئيسية لـ RentalDriveGo', menu: 'القائمة', mobileNav: 'التنقل عبر Carplace على الهاتف', pagination: 'صفحات نتائج البحث' },
|
||||
nav: { find: 'ابحث عن سيارة', companies: 'شركات التأجير', deals: 'العروض', how: 'كيف تعمل', help: 'المساعدة' },
|
||||
actions: { companySpace: 'مساحة الشركة', search: 'البحث عن سيارات', editSearch: 'تعديل البحث', viewVehicle: 'عرض السيارة', viewFleet: 'عرض الأسطول', request: 'اطلب هذه السيارة', back: 'رجوع', continue: 'متابعة', submit: 'إرسال طلب الحجز', retry: 'إعادة المحاولة' },
|
||||
hero: { eyebrow: 'RentalDriveGo Carplace', title: 'اعثر على سيارة الإيجار المناسبة دون غموض غير ضروري.', body: 'قارن سيارات شركات التأجير المحلية، وتحقق من التوفر حسب تواريخك، ثم أرسل طلباً واضحاً. تتولى الشركة التأكيد معك مباشرة.' },
|
||||
search: { title: 'خطط لرحلتك', pickup: 'موقع الاستلام', returnLocation: 'موقع الإرجاع', sameReturn: 'الإرجاع في نفس الموقع', differentReturn: 'الإرجاع في موقع آخر', pickupDate: 'تاريخ الاستلام', pickupTime: 'وقت الاستلام', returnDate: 'تاريخ الإرجاع', returnTime: 'وقت الإرجاع', age: 'عمر السائق', promo: 'الرمز الترويجي', optional: 'اختياري', locationPlaceholder: 'اختر مدينة', invalidDates: 'يجب أن يكون الإرجاع بعد الاستلام.' },
|
||||
trust: [
|
||||
['سيارات محلية', 'تصفح السيارات المنشورة من شركات تأجير نشطة.'],
|
||||
['تحقق حسب التاريخ', 'يتم التحقق من التوفر للتواريخ التي تختارها.'],
|
||||
['تأكيد مباشر', 'يبقى الطلب قيد الانتظار حتى تؤكده شركة التأجير.'],
|
||||
['تقديرات واضحة', 'المبالغ المعروضة تقديرية وليست ضمانات مختلقة.'],
|
||||
],
|
||||
sections: { cities: 'تصفح حسب المدينة', categories: 'اختر نوع السيارة', offers: 'العروض الحالية', vehicles: 'سيارات مقترحة', companies: 'شركات التأجير', how: 'كيف تعمل Carplace', guidance: 'قبل إرسال الطلب', faq: 'أسئلة شائعة', results: 'السيارات المتاحة', filters: 'تصفية النتائج' },
|
||||
categories: { ECONOMY: 'اقتصادية', COMPACT: 'مدمجة', MIDSIZE: 'متوسطة', FULLSIZE: 'كبيرة', SUV: 'دفع رباعي', LUXURY: 'فاخرة', VAN: 'فان', TRUCK: 'شاحنة' },
|
||||
how: [
|
||||
['ابحث', 'اختر الموقع وتواريخ الإيجار.'],
|
||||
['قارن', 'راجع السيارات والشركات والأسعار والشروط المهمة.'],
|
||||
['أرسل الطلب', 'أدخل معلومات الرحلة وبيانات التواصل بأمان.'],
|
||||
['احصل على التأكيد', 'تراجع الشركة الطلب وتتواصل معك مباشرة.'],
|
||||
],
|
||||
guidance: [
|
||||
['جهز الوثائق', 'قد تطلب الشركة وثيقة هوية ورخصة قيادة سارية قبل الاستلام.'],
|
||||
['توقع مبلغ ضمان', 'تحدد الشركة مبلغ الضمان والتأمين والكيلومترات والتوصيل.'],
|
||||
['راجع الشروط النهائية', 'يجب أن تؤكد الشركة التوفر والمبلغ النهائي قبل قبول الإيجار.'],
|
||||
],
|
||||
faq: [
|
||||
['هل يتم تأكيد الحجز فوراً؟', 'لا. ترسل Carplace طلباً، وتؤكد شركة التأجير التوفر والشروط النهائية معك مباشرة.'],
|
||||
['هل المبلغ المعروض هو السعر النهائي؟', 'إنه مجموع إيجار تقديري. قد تضاف الضرائب أو الضمان أو التأمين أو التوصيل أو رسوم الشركة.'],
|
||||
['هل أحتاج إلى حساب مستأجر؟', 'لا. تدعم Carplace حالياً إرسال طلب الحجز كزائر.'],
|
||||
],
|
||||
vehicle: { from: 'ابتداءً من', perDay: '/ اليوم', estimated: 'مجموع الإيجار التقديري', estimateDisclaimer: 'السعر النهائي والضمان والتأمين والضرائب والكيلومترات والتوصيل تحتاج إلى تأكيد الشركة.', available: 'متاحة للتواريخ المحددة', unavailable: 'غير متاحة للتواريخ المحددة', availableFrom: 'أقرب موعد متاح', company: 'شركة التأجير', rating: 'التقييم', new: 'إعلان جديد', seats: 'المقاعد', transmission: 'ناقل الحركة', fuel: 'الوقود', category: 'الفئة', year: 'السنة', features: 'المزايا المتضمنة', pickupLocations: 'مواقع الاستلام', noPhoto: 'صورة السيارة غير متاحة', directContact: 'تواصل مباشر مع الشركة', phone: 'اتصل بالشركة', whatsapp: 'واتساب الشركة', similar: 'سيارات مشابهة' },
|
||||
results: { title: 'سيارات تناسب رحلتك', count: 'سيارات متاحة', none: 'لا توجد سيارات مطابقة لهذا البحث حالياً.', noneBody: 'جرّب مدينة أخرى أو فئة أوسع أو تواريخ مختلفة.', error: 'نتائج البحث غير متاحة مؤقتاً.', sort: 'الترتيب', sortPriceAsc: 'الأقل سعراً يومياً', sortPriceDesc: 'الأعلى سعراً يومياً', allCategories: 'كل الفئات', maxPrice: 'أقصى سعر يومي', transmission: 'ناقل الحركة', make: 'الشركة المصنعة', model: 'الطراز', apply: 'تطبيق الفلاتر', clear: 'مسح الفلاتر', previous: 'السابق', next: 'التالي' },
|
||||
company: { partner: 'شركة تأجير على Carplace', fleet: 'الأسطول المتاح', offers: 'العروض الحالية', contact: 'تواصل مع الشركة', policies: 'معلومات الإيجار', noOffers: 'لا توجد عروض عامة نشطة.', vehicles: 'سيارات منشورة', reviews: 'تقييمات' },
|
||||
booking: { title: 'طلب حجز', stepTrip: 'الرحلة', stepContact: 'التواصل', stepReview: 'المراجعة', contactIntro: 'تستخدم الشركة هذه البيانات لتأكيد الطلب.', firstName: 'الاسم الأول', lastName: 'اسم العائلة', email: 'البريد الإلكتروني', phone: 'الهاتف', notes: 'ملاحظات للشركة', notesPlaceholder: 'رقم الرحلة أو طلب التوصيل أو معلومة مفيدة', reviewTitle: 'راجع طلبك', pendingNotice: 'إرسال النموذج لا يؤكد الحجز. ستراجع الشركة التوفر وتتواصل معك.', consent: 'أوافق على مشاركة بيانات التواصل وتفاصيل الرحلة مع شركة التأجير المحددة لمعالجة هذا الطلب.', successTitle: 'تم إرسال طلبك', successBody: 'ستراجع الشركة الطلب وتتواصل معك. لا تعتبر السيارة مؤكدة حتى تقبل الشركة الطلب.', reference: 'مرجع الطلب', status: 'الحالة', pending: 'بانتظار تأكيد الشركة', error: 'تعذر إرسال الطلب.', unavailable: 'لم تعد السيارة متاحة لهذه التواريخ.', privacy: 'سياسة الخصوصية', terms: 'الشروط' },
|
||||
footer: { description: 'سوق للمستأجرين من RentalDriveGo. ابحث محلياً، وأرسل طلباً واضحاً، واحصل على التأكيد مباشرة من شركة التأجير.', explore: 'استكشف', support: 'الدعم', business: 'لشركات التأجير', rights: 'جميع الحقوق محفوظة.', language: 'اللغة', theme: 'المظهر', light: 'فاتح', dark: 'داكن' },
|
||||
status: { unavailable: 'Carplace غير متاحة', unavailableBody: 'بيانات السوق غير متاحة مؤقتاً.' },
|
||||
},
|
||||
} as const
|
||||
|
||||
export type CarplaceMessages = (typeof messages)['en']
|
||||
|
||||
export function getCarplaceMessages(language: CarplaceLanguage): CarplaceMessages {
|
||||
return messages[language] as unknown as CarplaceMessages
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import type { CarplaceLanguage, OfferSummary } from './types'
|
||||
|
||||
export function formatOfferValue(offer: OfferSummary, language: CarplaceLanguage): string {
|
||||
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD', language)
|
||||
if (offer.type === 'FREE_DAY') {
|
||||
const days = Math.max(1, offer.discountValue)
|
||||
if (language === 'ar') return `${days} ${days === 1 ? 'يوم مجاني' : 'أيام مجانية'}`
|
||||
if (language === 'fr') return `${days} ${days === 1 ? 'jour offert' : 'jours offerts'}`
|
||||
return `${days} ${days === 1 ? 'free day' : 'free days'}`
|
||||
}
|
||||
if (offer.type === 'SPECIAL_RATE') {
|
||||
const rate = offer.specialRate ?? offer.discountValue
|
||||
const suffix = language === 'ar' ? '/يوم' : language === 'fr' ? '/jour' : '/day'
|
||||
return `${formatCurrency(rate, 'MAD', language)}${suffix}`
|
||||
}
|
||||
return `${offer.discountValue}%`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CARPLACE_BASE_PATH = '/carplace'
|
||||
|
||||
export function carplaceHref(path = ''): string {
|
||||
if (!path || path === '/') return CARPLACE_BASE_PATH
|
||||
return `${CARPLACE_BASE_PATH}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export function carplaceCompanyHref(slug: string): string {
|
||||
return carplaceHref(`/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function carplaceVehicleHref(slug: string, vehicleId: string, query?: URLSearchParams | string): string {
|
||||
const suffix = typeof query === 'string' ? query : query?.toString()
|
||||
return `${carplaceHref(`/${encodeURIComponent(slug)}/vehicles/${encodeURIComponent(vehicleId)}`)}${suffix ? `?${suffix}` : ''}`
|
||||
}
|
||||
|
||||
export function homepageHref(language: 'en' | 'fr' | 'ar'): string {
|
||||
return `/${language}`
|
||||
}
|
||||