chore: wire Carplace into dev and production stacks
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user