chore: wire Carplace into dev and production stacks
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s

This commit is contained in:
root
2026-07-02 18:15:42 -04:00
parent ad5f5ebab7
commit 7ff2dbb139
214 changed files with 5258 additions and 5906 deletions
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './CarplaceShell'
describe('CarplaceShell footer content registry', () => {
it('exposes exactly the supported carplace locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
})
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
const content = getFooterContent('en')
expect(content.localeLabel).toBe('Global (English)')
expect(content.rightsLabel).toBe('All rights reserved.')
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/carplace/app-privacy-en' })
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/carplace/footer/contact-sales' })
})
it('returns French labels while preserving canonical footer slugs', () => {
const content = getFooterContent('fr')
expect(content.localeLabel).toBe('Europe (French)')
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/carplace/footer/terms-of-service' })
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/carplace/footer/general-conditions' })
})
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
const content = getFooterContent('ar')
expect(content.localeLabel).toBe('North Africa (Arabic)')
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/carplace/app-privacy-ar' })
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
})
})
@@ -0,0 +1,213 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { appPrivacyHref, appTermsHref, footerPageHref } from '@/lib/footerContent'
import { isCarplaceLanguage, type CarplaceLanguage } from '@/lib/i18n'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
export const localeOptions: Array<{ value: CarplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'English', flag: '🇬🇧' },
{ value: 'ar', label: 'العربية', flag: '🇲🇦' },
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
]
type Dictionary = {
home: string
carplace: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
dark: string
preferences: string
}
const dictionaries: Record<CarplaceLanguage, Dictionary> = {
en: {
home: 'Home',
carplace: 'Carplace',
signIn: 'Sign in',
ownerSignIn: 'Create Agency Space',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
preferences: 'Carplace preferences',
},
fr: {
home: 'Accueil',
carplace: 'Carplace',
signIn: 'Connexion',
ownerSignIn: "Créer un espace d'agence",
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
preferences: 'Préférences Carplace',
},
ar: {
home: 'الرئيسية',
carplace: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
preferences: 'تفضيلات Carplace',
},
}
type FooterContent = {
localeLabel: string
rightsLabel: string
primary: Array<{ label: string; href: string }>
secondary: Array<{ label: string; href: string }>
}
const footerLabels: Record<CarplaceLanguage, FooterContent> = {
en: {
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
primary: [
{ label: 'Privacy Policy', href: appPrivacyHref.en },
{ label: 'Terms of Use', href: appTermsHref.en },
{ label: 'Security', href: footerPageHref.security },
],
secondary: [
{ label: 'About Us', href: footerPageHref['about-us'] },
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
],
},
fr: {
localeLabel: 'Europe (French)',
rightsLabel: 'Tous droits réservés.',
primary: [
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
{ label: 'Sécurité', href: footerPageHref.security },
],
secondary: [
{ label: 'À propos', href: footerPageHref['about-us'] },
{ label: 'Contact commercial', href: footerPageHref['contact-sales'] },
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
],
},
ar: {
localeLabel: 'North Africa (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
primary: [
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
{ label: 'شروط الاستخدام', href: appTermsHref.ar },
{ label: 'الأمان', href: footerPageHref.security },
],
secondary: [
{ label: 'من نحن', href: footerPageHref['about-us'] },
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
],
},
}
export function getFooterContent(language: CarplaceLanguage): FooterContent {
return footerLabels[language]
}
function detectBrowserLanguage(): CarplaceLanguage | null {
if (typeof navigator === 'undefined') return null
for (const language of Array.from(navigator.languages ?? [navigator.language])) {
const code = language.split('-')[0].toLowerCase()
if (isCarplaceLanguage(code)) return code
}
return null
}
type PreferencesContextValue = {
language: CarplaceLanguage
theme: Theme
dict: Dictionary
setLanguage: (language: CarplaceLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useCarplacePreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useCarplacePreferences must be used within CarplaceShell')
return context
}
export default function CarplaceShell({
children,
initialLanguage = 'en',
initialTheme = 'light',
}: {
children: React.ReactNode
initialLanguage?: CarplaceLanguage
initialTheme?: Theme
}) {
const router = useRouter()
const pathname = usePathname()
const [language, setLanguageState] = useState<CarplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<CarplaceLanguage>(initialLanguage)
function applyLanguage(nextLanguage: CarplaceLanguage) {
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage)
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: Theme) {
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
document.documentElement.style.colorScheme = nextTheme
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme)
}
setThemeState(nextTheme)
}
useEffect(() => {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY)
const resolvedLanguage = isCarplaceLanguage(storedLanguage) ? storedLanguage : detectBrowserLanguage()
if (resolvedLanguage) setLanguageState(resolvedLanguage)
const storedTheme = readScopedPreference(SHARED_THEME_KEY)
if (storedTheme === 'light' || storedTheme === 'dark') setThemeState(storedTheme)
setHydrated(true)
}, [])
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
if (!hydrated) return
writeScopedPreference(SHARED_LANGUAGE_KEY, language)
if (previousLanguage.current !== language) router.refresh()
previousLanguage.current = language
}, [hydrated, language, pathname, router])
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme
if (hydrated) writeScopedPreference(SHARED_THEME_KEY, theme)
}, [hydrated, theme])
const value = useMemo(
() => ({ language, theme, dict: dictionaries[language], setLanguage: applyLanguage, setTheme: applyTheme }),
[language, theme],
)
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
}
@@ -0,0 +1,64 @@
import React, { isValidElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/CarplaceShell', () => ({
useCarplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
}))
import FooterContentPage from './FooterContentPage'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement<Record<string, unknown>>[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
describe('FooterContentPage', () => {
beforeEach(() => {
preferenceState.language = 'en'
})
it('uses the current carplace language when no forced language is provided', () => {
preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).toContain('Besoin de plus de détails')
})
it('lets static app policy pages force their own locale', () => {
preferenceState.language = 'en'
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).not.toContain('Footer information')
})
it('marks Arabic content as right-aligned', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
const elements = collectElements(page)
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
})
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
const links = collectElements(page).filter((element) => element.type === 'a')
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
})
})
@@ -0,0 +1,107 @@
'use client'
import React from 'react'
import { useCarplacePreferences } from '@/components/CarplaceShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type CarplaceLanguage } from '@/lib/i18n'
const pageMeta = {
en: {
kicker: 'Footer information',
cta: 'Need more details?',
support: 'Contact the RentalDriveGo team through our official channels for business, support, or partnership requests.',
},
fr: {
kicker: 'Informations du pied de page',
cta: 'Besoin de plus de détails ?',
support: "Contactez l'équipe RentalDriveGo via nos canaux officiels pour toute demande commerciale, support ou partenariat.",
},
ar: {
kicker: 'معلومات التذييل',
cta: 'هل تحتاج إلى مزيد من التفاصيل؟',
support: 'تواصل مع فريق RentalDriveGo عبر القنوات الرسمية للاستفسارات التجارية أو الدعم أو الشراكات.',
},
} as const
const urlPattern = /(https?:\/\/[^\s]+)/g
function renderParagraphText(paragraph: string) {
const parts = paragraph.split(urlPattern)
return parts.map((part, index) => {
if (urlPattern.test(part)) {
urlPattern.lastIndex = 0
const match = part.match(/^(https?:\/\/[^\s]+?)([.,!?;:]*)$/)
const href = match?.[1] ?? part
const trailing = match?.[2] ?? ''
return (
<span key={`${part}-${index}`}>
<a
href={href}
className="text-orange-700 underline decoration-orange-300 underline-offset-4 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
>
{href}
</a>
{trailing}
</span>
)
}
urlPattern.lastIndex = 0
return <span key={`${part}-${index}`}>{part}</span>
})
}
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: CarplaceLanguage }) {
const { language } = useCarplacePreferences()
const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage]
const isArabic = activeLanguage === 'ar'
return (
<main className="site-page">
<div className="site-section mx-auto max-w-4xl">
<section className="site-panel overflow-hidden p-0">
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
<p className="site-kicker">
{meta.kicker}
</p>
<h1 className="site-title">
{content.title}
</h1>
</div>
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
{content.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
{content.sections?.map((section) => (
<section key={section.heading} className="space-y-4">
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
</section>
))}
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
{meta.cta}
</p>
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
</div>
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,64 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useCarplacePreferences } from '@/components/CarplaceShell'
import { getCarplaceMessages } from '@/lib/carplace/messages'
import { carplaceHref, homepageHref } from '@/lib/carplace/routes'
import { appPrivacyHref, appTermsHref } from '@/lib/footerContent'
export default function CarplaceFooter({ dashboardUrl }: { dashboardUrl: string }) {
const { language } = useCarplacePreferences()
const copy = getCarplaceMessages(language)
const year = new Date().getUTCFullYear()
return (
<footer className="border-t border-stone-200 bg-white dark:border-blue-900 dark:bg-blue-950">
<div className="shell grid gap-10 py-12 md:grid-cols-[1.4fr_1fr_1fr_1fr]">
<div>
<Link href={homepageHref(language)} className="inline-flex items-center gap-3 text-blue-950 no-underline dark:text-white">
<Image src="/rentaldrivego.png" alt="" width={42} height={42} className="rounded-xl" unoptimized />
<span><strong className="block text-lg">RentalDriveGo</strong><span className="text-xs font-bold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-400">Carplace</span></span>
</Link>
<p className="mt-5 max-w-md text-sm leading-7 text-stone-600 dark:text-slate-300">{copy.footer.description}</p>
</div>
<FooterGroup title={copy.footer.explore} items={[
[copy.nav.find, carplaceHref('/search')],
[copy.nav.companies, carplaceHref('/#companies')],
[copy.nav.deals, carplaceHref('/#offers')],
[copy.nav.how, carplaceHref('/#how-it-works')],
]} />
<FooterGroup title={copy.footer.support} items={[
[copy.nav.help, carplaceHref('/#help')],
[copy.booking.privacy, appPrivacyHref[language]],
[copy.booking.terms, appTermsHref[language]],
]} />
<FooterGroup title={copy.footer.business} items={[
[copy.actions.companySpace, dashboardUrl],
['RentalDriveGo', homepageHref(language)],
]} external />
</div>
<div className="border-t border-stone-200 dark:border-blue-900">
<div className="shell flex flex-col gap-2 py-5 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between dark:text-slate-400">
<p>© {year} RentalDriveGo. {copy.footer.rights}</p>
<p>{copy.tagline}</p>
</div>
</div>
</footer>
)
}
function FooterGroup({ title, items, external = false }: { title: string; items: Array<[string, string]>; external?: boolean }) {
return (
<div>
<h2 className="text-sm font-black text-blue-950 dark:text-white">{title}</h2>
<ul className="mt-4 grid gap-3 text-sm text-stone-600 dark:text-slate-300">
{items.map(([label, href]) => (
<li key={`${label}-${href}`}>
{external ? <a href={href} className="no-underline hover:text-orange-600">{label}</a> : <Link href={href} className="no-underline hover:text-orange-600">{label}</Link>}
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,122 @@
'use client'
import { Building2, ChevronDown, Menu, Moon, Search, Sun, X } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useCarplacePreferences, localeOptions } from '@/components/CarplaceShell'
import { getCarplaceMessages } from '@/lib/carplace/messages'
import { carplaceHref, homepageHref } from '@/lib/carplace/routes'
export default function CarplaceHeader({ dashboardUrl }: { dashboardUrl: string }) {
const { language, theme, setLanguage, setTheme } = useCarplacePreferences()
const copy = getCarplaceMessages(language)
const pathname = usePathname()
const [mobileOpen, setMobileOpen] = useState(false)
const [languageOpen, setLanguageOpen] = useState(false)
const languageRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
setMobileOpen(false)
}, [pathname])
useEffect(() => {
function closeMenus(event: MouseEvent) {
if (!languageRef.current?.contains(event.target as Node)) setLanguageOpen(false)
}
document.addEventListener('mousedown', closeMenus)
return () => document.removeEventListener('mousedown', closeMenus)
}, [])
const nav: Array<{ label: string; href: string; icon: typeof Search | null }> = [
{ label: copy.nav.find, href: carplaceHref('/search'), icon: Search },
{ label: copy.nav.companies, href: carplaceHref('/#companies'), icon: null },
{ label: copy.nav.deals, href: carplaceHref('/#offers'), icon: null },
{ label: copy.nav.how, href: carplaceHref('/#how-it-works'), icon: null },
{ label: copy.nav.help, href: carplaceHref('/#help'), icon: null },
]
const currentLocale = localeOptions.find((item) => item.value === language) ?? localeOptions[0]
return (
<header className="carplace-header">
<div className="shell flex min-h-[76px] items-center gap-4">
<Link href={homepageHref(language)} className="flex min-w-0 items-center gap-3 text-blue-950 no-underline dark:text-white" aria-label={copy.accessibility.home}>
<Image src="/rentaldrivego.png" alt="" width={38} height={38} className="h-[38px] w-[38px] rounded-xl object-cover" priority unoptimized />
<span className="min-w-0">
<span className="block truncate text-base font-black leading-tight tracking-[-0.02em]">RentalDriveGo</span>
<span className="block truncate text-[11px] font-bold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-400">{copy.brand}</span>
</span>
</Link>
<nav className="mx-auto hidden items-center gap-1 lg:flex" aria-label="Carplace">
{nav.map(({ label, href }) => (
<Link key={href} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-stone-700 no-underline transition hover:bg-blue-50 hover:text-blue-900 dark:text-slate-200 dark:hover:bg-blue-900/50 dark:hover:text-white">
{label}
</Link>
))}
</nav>
<div className="ms-auto hidden items-center gap-2 md:flex">
<div ref={languageRef} className="relative">
<button type="button" className="carplace-control" onClick={() => setLanguageOpen((value) => !value)} aria-expanded={languageOpen} aria-haspopup="menu">
<span aria-hidden="true">{currentLocale.flag}</span>
<span>{language.toUpperCase()}</span>
<ChevronDown className={`h-4 w-4 transition ${languageOpen ? 'rotate-180' : ''}`} />
</button>
{languageOpen ? (
<div className="carplace-menu end-0" role="menu">
{localeOptions.map((option) => (
<button key={option.value} type="button" role="menuitem" className="carplace-menu-item" onClick={() => { setLanguage(option.value); setLanguageOpen(false) }}>
<span aria-hidden="true">{option.flag}</span>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
<button type="button" className="carplace-icon-button" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} aria-label={theme === 'dark' ? copy.footer.light : copy.footer.dark}>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<a href={dashboardUrl} className="carplace-company-link">
<Building2 className="h-4 w-4" />
<span>{copy.actions.companySpace}</span>
</a>
</div>
<button type="button" className="carplace-icon-button ms-auto md:hidden" onClick={() => setMobileOpen((value) => !value)} aria-expanded={mobileOpen} aria-label={copy.accessibility.menu}>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{mobileOpen ? (
<div className="border-t border-stone-200 bg-white px-4 pb-5 pt-3 shadow-xl dark:border-blue-900 dark:bg-blue-950 md:hidden">
<nav className="grid gap-1" aria-label={copy.accessibility.mobileNav}>
{nav.map(({ label, href, icon: Icon }) => (
<Link key={href} href={href} className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-stone-700 no-underline hover:bg-blue-50 dark:text-slate-200 dark:hover:bg-blue-900/50">
{Icon ? <Icon className="h-4 w-4" /> : null}
{label}
</Link>
))}
</nav>
<div className="mt-4 grid grid-cols-3 gap-2 border-t border-stone-200 pt-4 dark:border-blue-900">
{localeOptions.map((option) => (
<button key={option.value} type="button" onClick={() => setLanguage(option.value)} className={`carplace-control justify-center ${language === option.value ? 'border-orange-500 text-orange-700 dark:text-orange-300' : ''}`}>
{option.flag} {option.value.toUpperCase()}
</button>
))}
</div>
<div className="mt-3 grid grid-cols-[auto_1fr] gap-2">
<button type="button" className="carplace-icon-button" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} aria-label={theme === 'dark' ? copy.footer.light : copy.footer.dark}>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<a href={dashboardUrl} className="carplace-company-link justify-center"><Building2 className="h-4 w-4" />{copy.actions.companySpace}</a>
</div>
</div>
) : null}
</header>
)
}
@@ -0,0 +1,109 @@
'use client'
import { CalendarDays, Clock3, MapPin, Search } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import type { CarplaceMessages } from '@/lib/carplace/messages'
import { carplaceHref } from '@/lib/carplace/routes'
export type CarplaceSearchValues = {
pickupLocation?: string
dropoffLocation?: string
dropoffMode?: 'same' | 'different'
pickupDate?: string
pickupTime?: string
returnDate?: string
returnTime?: string
driverAge?: string
promoCode?: string
}
export default function CarplaceSearchForm({ cities, copy, initial = {}, compact = false }: { cities: string[]; copy: CarplaceMessages; initial?: CarplaceSearchValues; compact?: boolean }) {
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>(initial.dropoffMode === 'different' ? 'different' : 'same')
const [dateError, setDateError] = useState<string | null>(null)
const today = new Date().toISOString().slice(0, 10)
function validateDates(event: FormEvent<HTMLFormElement>) {
const data = new FormData(event.currentTarget)
const pickup = String(data.get('pickupDate') ?? '')
const pickupTime = String(data.get('pickupTime') ?? '00:00')
const returned = String(data.get('returnDate') ?? '')
const returnTime = String(data.get('returnTime') ?? '00:00')
if (!pickup || !returned || new Date(`${returned}T${returnTime}:00`) <= new Date(`${pickup}T${pickupTime}:00`)) {
event.preventDefault()
setDateError(copy.search.invalidDates)
}
}
return (
<form action={carplaceHref('/search')} method="get" onSubmit={validateDates} className={compact ? 'carplace-search carplace-search-compact' : 'carplace-search'}>
{!compact ? <h2 className="text-xl font-black text-blue-950 dark:text-white">{copy.search.title}</h2> : null}
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<SearchField icon={MapPin} label={copy.search.pickup}>
<input list="carplace-cities" name="pickupLocation" defaultValue={initial.pickupLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
</SearchField>
<SearchField icon={MapPin} label={copy.search.returnLocation}>
{dropoffMode === 'same' ? (
<div className="carplace-input flex items-center text-stone-500 dark:text-slate-400">{copy.search.sameReturn}</div>
) : (
<input list="carplace-cities" name="dropoffLocation" defaultValue={initial.dropoffLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
)}
</SearchField>
<SearchField icon={CalendarDays} label={copy.search.pickupDate}>
<div className="grid grid-cols-[1fr_7rem] gap-2">
<input type="date" name="pickupDate" min={today} defaultValue={initial.pickupDate ?? ''} required className="carplace-input min-w-0" />
<input type="time" name="pickupTime" defaultValue={initial.pickupTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.pickupTime} />
</div>
</SearchField>
<SearchField icon={CalendarDays} label={copy.search.returnDate}>
<div className="grid grid-cols-[1fr_7rem] gap-2">
<input type="date" name="returnDate" min={today} defaultValue={initial.returnDate ?? ''} required className="carplace-input min-w-0" />
<input type="time" name="returnTime" defaultValue={initial.returnTime ?? '10:00'} required className="carplace-input min-w-0" aria-label={copy.search.returnTime} />
</div>
</SearchField>
</div>
<div className="mt-3 flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="flex flex-wrap gap-2" role="group" aria-label={copy.search.returnLocation}>
<label className={`carplace-choice ${dropoffMode === 'same' ? 'carplace-choice-active' : ''}`}>
<input type="radio" name="dropoffMode" value="same" checked={dropoffMode === 'same'} onChange={() => setDropoffMode('same')} className="sr-only" />
{copy.search.sameReturn}
</label>
<label className={`carplace-choice ${dropoffMode === 'different' ? 'carplace-choice-active' : ''}`}>
<input type="radio" name="dropoffMode" value="different" checked={dropoffMode === 'different'} onChange={() => setDropoffMode('different')} className="sr-only" />
{copy.search.differentReturn}
</label>
</div>
<div className="grid flex-1 gap-3 sm:grid-cols-2 lg:max-w-xl">
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
{copy.search.age}
<select name="driverAge" defaultValue={initial.driverAge ?? '25'} className="carplace-input">
<option value="18">18+</option><option value="21">21+</option><option value="23">23+</option><option value="25">25+</option><option value="30">30+</option>
</select>
</label>
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
{copy.search.promo} <span className="font-normal text-stone-400">({copy.search.optional})</span>
<input name="promoCode" defaultValue={initial.promoCode ?? ''} className="carplace-input" />
</label>
</div>
<button type="submit" className="carplace-primary-button lg:ms-auto">
<Search className="h-4 w-4" />
{copy.actions.search}
</button>
</div>
{dateError ? <p role="alert" className="mt-3 text-sm font-semibold text-red-700 dark:text-red-300">{dateError}</p> : null}
<datalist id="carplace-cities">{cities.map((city) => <option key={city} value={city} />)}</datalist>
<span className="sr-only"><Clock3 />{copy.search.pickupTime}</span>
</form>
)
}
function SearchField({ icon: Icon, label, children }: { icon: typeof MapPin; label: string; children: React.ReactNode }) {
return (
<label className="grid min-w-0 gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
<span className="flex items-center gap-2"><Icon className="h-4 w-4 text-orange-600 dark:text-orange-400" />{label}</span>
{children}
</label>
)
}
@@ -0,0 +1,331 @@
'use client'
import { AlertCircle, CalendarDays, Check, ChevronLeft, ChevronRight, Loader2, Mail, MapPin, Phone, UserRound } from 'lucide-react'
import { useMemo, useRef, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@rentaldrivego/types'
import type { CarplaceMessages } from '@/lib/carplace/messages'
import { carplaceHref } from '@/lib/carplace/routes'
import type { CarplaceLanguage, QuoteResponse, ReservationResult } from '@/lib/carplace/types'
import { CarplaceApiError, carplacePost } from '@/lib/api'
export type BookingInitialValues = {
pickupLocation?: string
dropoffLocation?: string
pickupDate?: string
pickupTime?: string
returnDate?: string
returnTime?: string
promoCode?: string
driverAge?: string
}
type FormState = Required<Omit<BookingInitialValues, 'promoCode'>> & {
promoCode: string
firstName: string
lastName: string
email: string
phone: string
notes: string
consent: boolean
}
export default function ProgressiveBookingFlow({
vehicleId,
companySlug,
vehicleName,
companyName,
dailyRate,
pickupLocations,
allowDifferentDropoff,
dropoffLocations,
language,
copy,
initial,
}: {
vehicleId: string
companySlug: string
vehicleName: string
companyName: string
dailyRate: number
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
language: CarplaceLanguage
copy: CarplaceMessages
initial?: BookingInitialValues
}) {
const router = useRouter()
const idempotencyKey = useRef<string>(createIdempotencyKey())
const [step, setStep] = useState(1)
const [quote, setQuote] = useState<QuoteResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState<FormState>({
pickupLocation: initial?.pickupLocation || pickupLocations[0] || '',
dropoffLocation: initial?.dropoffLocation || initial?.pickupLocation || pickupLocations[0] || '',
pickupDate: initial?.pickupDate || '',
pickupTime: initial?.pickupTime || '10:00',
returnDate: initial?.returnDate || '',
returnTime: initial?.returnTime || '10:00',
promoCode: initial?.promoCode || '',
driverAge: initial?.driverAge || '25',
firstName: '',
lastName: '',
email: '',
phone: '',
notes: '',
consent: false,
})
const today = new Date().toISOString().slice(0, 10)
const startIso = useMemo(() => toIso(form.pickupDate, form.pickupTime), [form.pickupDate, form.pickupTime])
const endIso = useMemo(() => toIso(form.returnDate, form.returnTime), [form.returnDate, form.returnTime])
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((current) => ({ ...current, [key]: value }))
setError(null)
}
async function track(eventName: string, metadata?: Record<string, string | number | boolean | null>) {
try {
await carplacePost('/carplace/events', {
eventName,
companySlug,
vehicleId,
path: window.location.pathname,
metadata,
})
} catch {}
}
async function validateTrip() {
if (!form.pickupLocation || !form.pickupDate || !form.returnDate || !startIso || !endIso || new Date(endIso) <= new Date(startIso)) {
setError(copy.search.invalidDates)
return
}
setLoading(true)
setError(null)
try {
const nextQuote = await carplacePost<QuoteResponse>('/carplace/quotes', {
vehicleId,
startDate: startIso,
endDate: endIso,
pickupLocation: form.pickupLocation,
returnLocation: form.dropoffLocation || form.pickupLocation,
promoCode: form.promoCode || undefined,
})
setQuote(nextQuote)
if (!nextQuote.available) {
setError(copy.booking.unavailable)
return
}
setStep(2)
void track('trip_dates_selected', { rentalDays: nextQuote.rentalDays })
} catch (caught) {
const apiError = caught as CarplaceApiError
if (apiError.code === 'unavailable') setError(copy.booking.unavailable)
else if (apiError.code === 'invalid_dates') setError(copy.search.invalidDates)
else setError(copy.booking.error)
} finally {
setLoading(false)
}
}
function validateContact() {
if (!form.firstName.trim() || !form.lastName.trim() || !form.email.trim() || !form.phone.trim()) {
setError(copy.booking.error)
return
}
setStep(3)
void track('contact_details_started')
}
async function submit() {
if (!form.consent || !startIso || !endIso) {
setError(copy.booking.error)
return
}
setLoading(true)
setError(null)
try {
// Recheck price and availability immediately before creating the request.
const refreshedQuote = await carplacePost<QuoteResponse>('/carplace/quotes', {
vehicleId,
startDate: startIso,
endDate: endIso,
pickupLocation: form.pickupLocation,
returnLocation: form.dropoffLocation || form.pickupLocation,
promoCode: form.promoCode || undefined,
})
if (!refreshedQuote.available) {
setStep(1)
setQuote(refreshedQuote)
setError(copy.booking.unavailable)
return
}
setQuote(refreshedQuote)
const result = await carplacePost<ReservationResult>('/carplace/reservations', {
vehicleId,
companySlug,
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
driverAge: Number(form.driverAge),
startDate: startIso,
endDate: endIso,
pickupLocation: form.pickupLocation,
returnLocation: form.dropoffLocation || form.pickupLocation,
promoCode: form.promoCode || undefined,
notes: form.notes.trim() || undefined,
language,
idempotencyKey: idempotencyKey.current,
}, {
headers: { 'Idempotency-Key': idempotencyKey.current },
})
const reference = result.bookingReference || result.reservationId
try {
sessionStorage.setItem(`carplace-request:${reference}`, JSON.stringify({ ...result, quote: refreshedQuote, vehicleName, companyName }))
} catch {}
void track('booking_request_success', { reference })
router.push(carplaceHref(`/request/${encodeURIComponent(reference)}`))
} catch (caught) {
const apiError = caught as CarplaceApiError
void track('booking_request_failed', { code: apiError.code ?? 'unknown' })
if (apiError.code === 'unavailable') {
setStep(1)
setError(copy.booking.unavailable)
} else if (apiError.code === 'invalid_dates') {
setStep(1)
setError(copy.search.invalidDates)
} else {
setError(copy.booking.error)
}
} finally {
setLoading(false)
}
}
return (
<section className="carplace-booking" aria-labelledby="booking-title">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">{copy.vehicle.estimated}</p>
<h2 id="booking-title" className="mt-1 text-xl font-black text-blue-950 dark:text-white">{copy.booking.title}</h2>
</div>
<p className="text-sm font-black text-blue-950 dark:text-white">{formatCurrency(dailyRate, 'MAD', language)} <span className="text-xs font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.perDay}</span></p>
</div>
<ol className="mt-5 grid grid-cols-3 gap-2" aria-label={copy.booking.title}>
{[copy.booking.stepTrip, copy.booking.stepContact, copy.booking.stepReview].map((label, index) => {
const number = index + 1
const active = number === step
const done = number < step
return <li key={label} className={`carplace-step ${active ? 'carplace-step-active' : ''} ${done ? 'carplace-step-done' : ''}`}><span>{done ? <Check className="h-3.5 w-3.5" /> : number}</span>{label}</li>
})}
</ol>
{error ? <div className="mt-4 flex gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200"><AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />{error}</div> : null}
{step === 1 ? (
<div className="mt-5 grid gap-4">
<BookingField label={copy.search.pickup} icon={MapPin}>
<select className="carplace-input" value={form.pickupLocation} onChange={(event) => { update('pickupLocation', event.target.value); if (!allowDifferentDropoff) update('dropoffLocation', event.target.value) }} required>
{pickupLocations.length === 0 ? <option value="">{copy.search.locationPlaceholder}</option> : null}
{pickupLocations.map((location) => <option key={location}>{location}</option>)}
</select>
</BookingField>
<BookingField label={copy.search.returnLocation} icon={MapPin}>
{allowDifferentDropoff ? (
<select className="carplace-input" value={form.dropoffLocation} onChange={(event) => update('dropoffLocation', event.target.value)}>
{[...new Set([form.pickupLocation, ...dropoffLocations])].filter(Boolean).map((location) => <option key={location}>{location}</option>)}
</select>
) : <div className="carplace-input flex items-center text-stone-600 dark:text-slate-300">{form.pickupLocation || copy.search.sameReturn}</div>}
</BookingField>
<div className="grid gap-3 sm:grid-cols-2">
<BookingField label={copy.search.pickupDate} icon={CalendarDays}>
<div className="grid grid-cols-[1fr_6.5rem] gap-2"><input type="date" min={today} className="carplace-input min-w-0" value={form.pickupDate} onChange={(event) => update('pickupDate', event.target.value)} /><input type="time" className="carplace-input min-w-0" value={form.pickupTime} onChange={(event) => update('pickupTime', event.target.value)} /></div>
</BookingField>
<BookingField label={copy.search.returnDate} icon={CalendarDays}>
<div className="grid grid-cols-[1fr_6.5rem] gap-2"><input type="date" min={form.pickupDate || today} className="carplace-input min-w-0" value={form.returnDate} onChange={(event) => update('returnDate', event.target.value)} /><input type="time" className="carplace-input min-w-0" value={form.returnTime} onChange={(event) => update('returnTime', event.target.value)} /></div>
</BookingField>
</div>
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.search.age}<select className="carplace-input" value={form.driverAge} onChange={(event) => update('driverAge', event.target.value)}><option value="18">18+</option><option value="21">21+</option><option value="23">23+</option><option value="25">25+</option><option value="30">30+</option></select></label>
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.search.promo} <span className="font-normal text-stone-400">({copy.search.optional})</span><input className="carplace-input" value={form.promoCode} onChange={(event) => update('promoCode', event.target.value)} /></label>
{quote ? <QuoteBox quote={quote} language={language} copy={copy} /> : null}
<button type="button" className="carplace-primary-button justify-center" onClick={validateTrip} disabled={loading}>{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ChevronRight className="h-4 w-4 rtl:rotate-180" />}{copy.actions.continue}</button>
</div>
) : null}
{step === 2 ? (
<div className="mt-5 grid gap-4">
<p className="text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.booking.contactIntro}</p>
<div className="grid gap-3 sm:grid-cols-2">
<BookingField label={copy.booking.firstName} icon={UserRound}><input className="carplace-input" value={form.firstName} onChange={(event) => update('firstName', event.target.value)} autoComplete="given-name" /></BookingField>
<BookingField label={copy.booking.lastName} icon={UserRound}><input className="carplace-input" value={form.lastName} onChange={(event) => update('lastName', event.target.value)} autoComplete="family-name" /></BookingField>
</div>
<BookingField label={copy.booking.email} icon={Mail}><input type="email" className="carplace-input" value={form.email} onChange={(event) => update('email', event.target.value)} autoComplete="email" /></BookingField>
<BookingField label={copy.booking.phone} icon={Phone}><input type="tel" className="carplace-input" value={form.phone} onChange={(event) => update('phone', event.target.value)} autoComplete="tel" /></BookingField>
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.booking.notes} <span className="font-normal text-stone-400">({copy.search.optional})</span><textarea className="carplace-input min-h-24 resize-y" value={form.notes} onChange={(event) => update('notes', event.target.value)} placeholder={copy.booking.notesPlaceholder} maxLength={500} /></label>
<div className="grid grid-cols-2 gap-2"><button type="button" className="carplace-secondary-button justify-center" onClick={() => setStep(1)}><ChevronLeft className="h-4 w-4 rtl:rotate-180" />{copy.actions.back}</button><button type="button" className="carplace-primary-button justify-center" onClick={validateContact}><ChevronRight className="h-4 w-4 rtl:rotate-180" />{copy.actions.continue}</button></div>
</div>
) : null}
{step === 3 ? (
<div className="mt-5 grid gap-4">
<h3 className="text-base font-black text-blue-950 dark:text-white">{copy.booking.reviewTitle}</h3>
<dl className="grid gap-3 rounded-2xl bg-stone-50 p-4 text-sm dark:bg-blue-950/60">
<ReviewRow label={copy.vehicle.company} value={companyName} />
<ReviewRow label={copy.actions.viewVehicle} value={vehicleName} />
<ReviewRow label={copy.search.pickup} value={`${form.pickupLocation} · ${formatLocalDate(startIso, language)}`} />
<ReviewRow label={copy.search.returnLocation} value={`${form.dropoffLocation || form.pickupLocation} · ${formatLocalDate(endIso, language)}`} />
<ReviewRow label={copy.booking.email} value={form.email} />
<ReviewRow label={copy.booking.phone} value={form.phone} />
<ReviewRow label={copy.search.age} value={`${form.driverAge}+`} />
</dl>
{quote ? <QuoteBox quote={quote} language={language} copy={copy} /> : null}
<p className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm leading-6 text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">{copy.booking.pendingNotice}</p>
<label className="flex items-start gap-3 text-sm leading-6 text-stone-700 dark:text-slate-200"><input type="checkbox" className="mt-1 h-4 w-4 accent-orange-600" checked={form.consent} onChange={(event) => update('consent', event.target.checked)} /><span>{copy.booking.consent}</span></label>
<div className="grid grid-cols-2 gap-2"><button type="button" className="carplace-secondary-button justify-center" onClick={() => setStep(2)} disabled={loading}><ChevronLeft className="h-4 w-4 rtl:rotate-180" />{copy.actions.back}</button><button type="button" className="carplace-primary-button justify-center" onClick={submit} disabled={loading || !form.consent}>{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}{copy.actions.submit}</button></div>
</div>
) : null}
</section>
)
}
function BookingField({ label, icon: Icon, children }: { label: string; icon: typeof MapPin; children: ReactNode }) {
return <label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300"><span className="flex items-center gap-2"><Icon className="h-4 w-4 text-orange-600 dark:text-orange-400" />{label}</span>{children}</label>
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return <div className="grid gap-1 sm:grid-cols-[8rem_1fr]"><dt className="font-semibold text-stone-500 dark:text-slate-400">{label}</dt><dd className="font-semibold text-blue-950 dark:text-white">{value}</dd></div>
}
function QuoteBox({ quote, language, copy }: { quote: QuoteResponse; language: CarplaceLanguage; copy: CarplaceMessages }) {
return <div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/60"><div className="flex items-center justify-between gap-3"><div><p className="text-xs font-bold uppercase tracking-[0.14em] text-blue-700 dark:text-blue-300">{copy.vehicle.estimated}</p><p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{quote.rentalDays} × {formatCurrency(quote.dailyRate, 'MAD', language)}</p></div><p className="text-xl font-black text-blue-950 dark:text-white">{formatCurrency(quote.estimatedTotal, 'MAD', language)}</p></div><p className="mt-3 text-xs leading-5 text-stone-600 dark:text-slate-300">{copy.vehicle.estimateDisclaimer}</p></div>
}
function toIso(date: string, time: string): string | null {
if (!date || !time) return null
const value = new Date(`${date}T${time}:00`)
return Number.isNaN(value.getTime()) ? null : value.toISOString()
}
function formatLocalDate(value: string | null, language: CarplaceLanguage): string {
if (!value) return ''
return new Intl.DateTimeFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-MA' : 'en-MA', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
function createIdempotencyKey(): string {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
const random = Math.floor(Math.random() * 16)
const value = character === 'x' ? random : (random & 0x3) | 0x8
return value.toString(16)
})
}
@@ -0,0 +1,40 @@
'use client'
import { CheckCircle2, Clock3, Search } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useCarplacePreferences } from '@/components/CarplaceShell'
import { getCarplaceMessages } from '@/lib/carplace/messages'
import { carplaceHref } from '@/lib/carplace/routes'
import type { ReservationResult } from '@/lib/carplace/types'
export default function RequestSuccessClient({ reference }: { reference: string }) {
const { language } = useCarplacePreferences()
const copy = getCarplaceMessages(language)
const [result, setResult] = useState<ReservationResult | null>(null)
useEffect(() => {
try {
const raw = sessionStorage.getItem(`carplace-request:${reference}`)
if (raw) setResult(JSON.parse(raw) as ReservationResult)
} catch {}
}, [reference])
return (
<main className="site-page">
<div className="shell py-16 sm:py-24">
<section className="mx-auto max-w-2xl rounded-[2rem] border border-emerald-200 bg-white p-8 text-center shadow-[0_30px_80px_rgba(15,23,42,0.10)] dark:border-emerald-900 dark:bg-blue-950 sm:p-12">
<CheckCircle2 className="mx-auto h-14 w-14 text-emerald-600 dark:text-emerald-400" />
<p className="mt-6 text-xs font-bold uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-300">Carplace</p>
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-white">{copy.booking.successTitle}</h1>
<p className="mt-4 leading-7 text-stone-600 dark:text-slate-300">{copy.booking.successBody}</p>
<dl className="mt-8 grid gap-3 rounded-2xl bg-stone-50 p-5 text-start dark:bg-blue-900/30">
<div className="flex items-center justify-between gap-4"><dt className="text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.booking.reference}</dt><dd className="font-black text-blue-950 dark:text-white">{result?.bookingReference || reference}</dd></div>
<div className="flex items-center justify-between gap-4"><dt className="text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.booking.status}</dt><dd className="inline-flex items-center gap-2 rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-900 dark:bg-amber-950 dark:text-amber-100"><Clock3 className="h-3.5 w-3.5" />{copy.booking.pending}</dd></div>
</dl>
<Link href={carplaceHref('/search')} className="carplace-primary-button mt-8 justify-center"><Search className="h-4 w-4" />{copy.actions.search}</Link>
</section>
</div>
</main>
)
}
@@ -0,0 +1,64 @@
import { ArrowRight, Fuel, Gauge, MapPin, Star, Users } from 'lucide-react'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import type { CarplaceMessages } from '@/lib/carplace/messages'
import { carplaceVehicleHref } from '@/lib/carplace/routes'
import type { CarplaceLanguage, VehicleSummary } from '@/lib/carplace/types'
export default function VehicleCard({ vehicle, copy, language, query }: { vehicle: VehicleSummary; copy: CarplaceMessages; language: CarplaceLanguage; query?: URLSearchParams | string }) {
const brand = vehicle.company.brand
const companySlug = brand?.subdomain || vehicle.company.slug
const available = vehicle.availability !== false
const location = vehicle.pickupLocations?.[0] || brand?.publicCity
return (
<article className="carplace-vehicle-card">
<div className="relative aspect-[16/10] overflow-hidden bg-gradient-to-br from-blue-100 to-stone-100 dark:from-blue-900 dark:to-blue-950">
{vehicle.photos?.[0] ? (
// Kept unoptimized because company uploads can come from configured storage hosts.
// eslint-disable-next-line @next/next/no-img-element
<img src={vehicle.photos[0]} alt={`${vehicle.year} ${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover transition duration-500 group-hover:scale-105" loading="lazy" />
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.noPhoto}</div>
)}
<span className={`absolute start-3 top-3 rounded-full px-3 py-1 text-xs font-bold shadow-sm ${available ? 'bg-emerald-600 text-white' : 'bg-stone-900/85 text-white'}`}>
{available ? copy.vehicle.available : copy.vehicle.unavailable}
</span>
</div>
<div className="flex flex-1 flex-col p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-xs font-bold uppercase tracking-[0.16em] text-orange-600 dark:text-orange-400">{copy.categories[vehicle.category as keyof typeof copy.categories] ?? vehicle.category}</p>
<h3 className="mt-2 truncate text-xl font-black tracking-[-0.02em] text-blue-950 dark:text-white">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{vehicle.year}</p>
</div>
{brand?.carplaceRating ? (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950/50 dark:text-amber-200"><Star className="h-3.5 w-3.5 fill-current" />{brand.carplaceRating.toFixed(1)}</span>
) : null}
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-semibold text-stone-600 dark:text-slate-300">
{vehicle.transmission ? <span className="carplace-spec"><Gauge className="h-3.5 w-3.5" />{vehicle.transmission}</span> : null}
{vehicle.seats ? <span className="carplace-spec"><Users className="h-3.5 w-3.5" />{vehicle.seats}</span> : null}
{vehicle.fuelType ? <span className="carplace-spec"><Fuel className="h-3.5 w-3.5" />{vehicle.fuelType}</span> : null}
</div>
<div className="mt-4 border-t border-stone-200 pt-4 dark:border-blue-900">
<p className="truncate text-sm font-semibold text-stone-700 dark:text-slate-200">{brand?.displayName ?? copy.vehicle.company}</p>
{location ? <p className="mt-1 flex items-center gap-1.5 text-xs text-stone-500 dark:text-slate-400"><MapPin className="h-3.5 w-3.5" />{location}</p> : null}
</div>
<div className="mt-auto flex items-end justify-between gap-4 pt-5">
<div>
<p className="text-xs text-stone-500 dark:text-slate-400">{copy.vehicle.from}</p>
<p className="text-xl font-black text-blue-950 dark:text-white">{formatCurrency(vehicle.dailyRate, 'MAD', language)} <span className="text-xs font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.perDay}</span></p>
</div>
<Link href={carplaceVehicleHref(companySlug, vehicle.id, query)} className="carplace-card-action" aria-label={`${copy.actions.viewVehicle}: ${vehicle.make} ${vehicle.model}`}>
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
</Link>
</div>
</div>
</article>
)
}