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,102 @@
|
||||
import { BadgeCheck, Building2, CalendarDays, MapPin, MessageCircle, Phone, ShieldCheck, Star } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { formatOfferValue } from '@/lib/carplace/offers'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CompanyBrand, OfferSummary, VehicleSummary } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
interface CompanyProfile {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
brand: (CompanyBrand & Record<string, unknown>) | null
|
||||
vehicles: Array<Omit<VehicleSummary, 'company'>>
|
||||
offers: OfferSummary[]
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: string
|
||||
overallRating: number
|
||||
comment?: string | null
|
||||
createdAt: string
|
||||
renter?: { firstName: string; lastName: string }
|
||||
}
|
||||
|
||||
export default async function CarplaceCompanyPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const resolvedParams = await params
|
||||
const { slug } = resolvedParams
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const [company, reviews] = await Promise.all([
|
||||
carplaceFetchOrDefault<CompanyProfile | null>(`/carplace/${encodeURIComponent(slug)}`, null),
|
||||
carplaceFetchOrDefault<Review[]>(`/carplace/${encodeURIComponent(slug)}/reviews`, []),
|
||||
])
|
||||
|
||||
if (!company) {
|
||||
return <main className="site-page"><div className="shell py-20"><section className="mx-auto max-w-2xl rounded-[2rem] border border-stone-200 bg-white p-8 text-center dark:border-blue-900 dark:bg-blue-950"><Building2 className="mx-auto h-12 w-12 text-stone-400" /><h1 className="mt-5 text-3xl font-black text-blue-950 dark:text-white">{copy.status.unavailable}</h1><p className="mt-3 text-stone-600 dark:text-slate-300">{copy.status.unavailableBody}</p><Link href={carplaceHref()} className="carplace-primary-button mt-7 justify-center">{copy.actions.back}</Link></section></div></main>
|
||||
}
|
||||
|
||||
const brand = company.brand
|
||||
const name = brand?.displayName || company.name
|
||||
const vehicles: VehicleSummary[] = company.vehicles.map((vehicle) => ({ ...vehicle, company: { slug: company.slug, brand } }))
|
||||
const description = localizedBrandValue(brand, 'description', language)
|
||||
const policies = [
|
||||
localizedBrandValue(brand, 'depositPolicySummary', language),
|
||||
localizedBrandValue(brand, 'mileagePolicySummary', language),
|
||||
localizedBrandValue(brand, 'cancellationPolicySummary', language),
|
||||
localizedBrandValue(brand, 'requiredDocumentsSummary', language),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="relative overflow-hidden border-b border-stone-200 bg-blue-950 text-white dark:border-blue-900">
|
||||
{typeof brand?.coverImageUrl === 'string' && brand.coverImageUrl ? <div className="absolute inset-0 opacity-30"><img src={brand.coverImageUrl} alt="" className="h-full w-full object-cover" /></div> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-950 via-blue-950/90 to-blue-900/60" />
|
||||
<div className="shell relative py-12 sm:py-16">
|
||||
<nav className="text-sm text-blue-200"><Link href={carplaceHref()} className="text-blue-200 no-underline hover:text-white">Carplace</Link><span className="mx-2">/</span><span>{name}</span></nav>
|
||||
<div className="mt-8 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-300"><BadgeCheck className="h-4 w-4" />{copy.company.partner}</p>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-[-0.04em] sm:text-5xl">{name}</h1>
|
||||
{description ? <p className="mt-5 max-w-2xl text-base leading-8 text-blue-100">{description}</p> : null}
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-blue-100">
|
||||
{brand?.publicCity ? <span className="carplace-dark-pill"><MapPin className="h-4 w-4" />{brand.publicCity}{brand.publicCountry ? `, ${brand.publicCountry}` : ''}</span> : null}
|
||||
<span className="carplace-dark-pill"><CalendarDays className="h-4 w-4" />{vehicles.length} {copy.company.vehicles}</span>
|
||||
{brand?.carplaceRating ? <span className="carplace-dark-pill"><Star className="h-4 w-4 fill-current text-amber-300" />{brand.carplaceRating.toFixed(1)} · {reviews.length} {copy.company.reviews}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{brand?.publicPhone ? <a href={`tel:${brand.publicPhone}`} className="carplace-light-button"><Phone className="h-4 w-4" />{copy.vehicle.phone}</a> : null}
|
||||
{brand?.whatsappNumber ? <a href={`https://wa.me/${brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noreferrer" className="carplace-orange-button"><MessageCircle className="h-4 w-4" />{copy.vehicle.whatsapp}</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shell py-12">
|
||||
{company.offers.length > 0 ? <section><h2 className="text-2xl font-black text-blue-950 dark:text-white">{copy.company.offers}</h2><div className="mt-5 grid gap-4 md:grid-cols-2 lg:grid-cols-3">{company.offers.map((offer) => <article key={offer.id} className="rounded-2xl border border-orange-200 bg-orange-50 p-5 dark:border-orange-900 dark:bg-orange-950/30"><div className="flex items-start justify-between gap-3"><h3 className="font-black text-blue-950 dark:text-white">{offer.title}</h3><span className="rounded-xl bg-orange-600 px-3 py-1.5 text-lg font-black text-white">{formatOfferValue(offer, language)}</span></div><p className="mt-4 text-xs text-stone-500 dark:text-slate-400">{new Intl.DateTimeFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-MA' : 'en-MA', { dateStyle: 'medium' }).format(new Date(offer.validUntil))}</p></article>)}</div></section> : null}
|
||||
|
||||
<section className={company.offers.length > 0 ? 'mt-14' : ''}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">{name}</p><h2 className="mt-2 text-3xl font-black tracking-[-0.035em] text-blue-950 dark:text-white">{copy.company.fleet}</h2></div><Link href={`${carplaceHref('/search')}?pickupLocation=${encodeURIComponent(brand?.publicCity || '')}`} className="carplace-secondary-button">{copy.actions.search}</Link></div>
|
||||
{vehicles.length > 0 ? <div className="mt-7 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{vehicles.map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} />)}</div> : <p className="mt-6 rounded-2xl border border-dashed border-stone-300 p-8 text-center text-stone-500 dark:border-blue-800 dark:text-slate-400">{copy.results.none}</p>}
|
||||
</section>
|
||||
|
||||
<div className="mt-14 grid gap-6 lg:grid-cols-2">
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-7 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-xl font-black text-blue-950 dark:text-white"><ShieldCheck className="h-5 w-5 text-emerald-600" />{copy.company.policies}</h2>{policies.length > 0 ? <ul className="mt-5 grid gap-3 text-sm leading-6 text-stone-600 dark:text-slate-300">{policies.map((policy) => <li key={policy} className="rounded-xl bg-stone-50 p-4 dark:bg-blue-900/30">{policy}</li>)}</ul> : <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-slate-400">{copy.vehicle.estimateDisclaimer}</p>}</section>
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-7 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-xl font-black text-blue-950 dark:text-white"><Star className="h-5 w-5 text-amber-500" />{copy.vehicle.rating}</h2>{reviews.length > 0 ? <div className="mt-5 grid gap-4">{reviews.slice(0, 4).map((review) => <blockquote key={review.id} className="rounded-xl bg-stone-50 p-4 dark:bg-blue-900/30"><p className="font-bold text-amber-600">{'★'.repeat(Math.max(1, Math.min(5, review.overallRating)))}</p>{review.comment ? <p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">“{review.comment}”</p> : null}<footer className="mt-2 text-xs text-stone-500">{review.renter ? `${review.renter.firstName} ${review.renter.lastName.slice(0, 1)}.` : copy.vehicle.new}</footer></blockquote>)}</div> : <p className="mt-4 text-sm text-stone-500 dark:text-slate-400">{copy.vehicle.new}</p>}</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function localizedBrandValue(brand: (CompanyBrand & Record<string, unknown>) | null, key: string, language: 'en' | 'fr' | 'ar'): string | null {
|
||||
if (!brand) return null
|
||||
const localized = brand[`${key}${language.toUpperCase()}`]
|
||||
if (typeof localized === 'string' && localized.trim()) return localized.trim()
|
||||
const direct = brand[key]
|
||||
return typeof direct === 'string' && direct.trim() ? direct.trim() : null
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ArrowLeft, BadgeCheck, Fuel, Gauge, MapPin, MessageCircle, Phone, ShieldCheck, Star, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import ProgressiveBookingFlow, { type BookingInitialValues } from '@/components/carplace/ProgressiveBookingFlow'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceCompanyHref, carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CompanyBrand, VehicleSearchResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
interface VehicleDetail {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
seats?: number | null
|
||||
transmission?: string | null
|
||||
fuelType?: string | null
|
||||
features?: string[]
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: { slug: string; name?: string; brand: CompanyBrand | null }
|
||||
}
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export default async function CarplaceVehiclePage({ params, searchParams }: { params: Promise<{ slug: string; id: string }>; searchParams?: Promise<RawSearchParams> }) {
|
||||
const [resolvedParams, rawParams] = await Promise.all([params, searchParams ?? Promise.resolve({})])
|
||||
const { slug, id } = resolvedParams
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const vehicle = await carplaceFetchOrDefault<VehicleDetail | null>(`/carplace/${encodeURIComponent(slug)}/vehicles/${encodeURIComponent(id)}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
return <main className="site-page"><div className="shell py-20"><section className="mx-auto max-w-2xl rounded-[2rem] border border-stone-200 bg-white p-8 text-center dark:border-blue-900 dark:bg-blue-950"><h1 className="text-3xl font-black text-blue-950 dark:text-white">{copy.status.unavailable}</h1><p className="mt-3 text-stone-600 dark:text-slate-300">{copy.status.unavailableBody}</p><Link href={carplaceHref()} className="carplace-primary-button mt-7 justify-center">{copy.actions.back}</Link></section></div></main>
|
||||
}
|
||||
|
||||
const companyName = vehicle.company.brand?.displayName || vehicle.company.name || copy.vehicle.company
|
||||
const companySlug = vehicle.company.brand?.subdomain || vehicle.company.slug || slug
|
||||
const first = vehicle.photos?.[0]
|
||||
const rest = vehicle.photos?.slice(1, 5) ?? []
|
||||
const related = await carplaceFetchOrDefault<VehicleSearchResponse>(`/carplace/search?category=${encodeURIComponent(vehicle.category)}&pageSize=4`, { items: [], pagination: { page: 1, pageSize: 4, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } })
|
||||
const similar = related.items.filter((item) => item.id !== vehicle.id).slice(0, 3)
|
||||
const initial = buildInitial(rawParams)
|
||||
const preserveQuery = buildPreserveQuery(rawParams)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<div className="shell py-8 sm:py-12">
|
||||
<nav className="flex flex-wrap items-center gap-2 text-sm text-stone-500 dark:text-slate-400">
|
||||
<Link href={carplaceHref()} className="no-underline hover:text-orange-600">Carplace</Link><span>/</span>
|
||||
<Link href={carplaceCompanyHref(companySlug)} className="no-underline hover:text-orange-600">{companyName}</Link><span>/</span>
|
||||
<span className="font-semibold text-blue-950 dark:text-white">{vehicle.make} {vehicle.model}</span>
|
||||
</nav>
|
||||
|
||||
<div className="mt-7 grid gap-8 lg:grid-cols-[minmax(0,1fr)_410px]">
|
||||
<div className="min-w-0 space-y-7">
|
||||
<section className="grid gap-3 sm:grid-cols-[2fr_1fr]">
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-[2rem] bg-gradient-to-br from-blue-100 to-stone-100 dark:from-blue-900 dark:to-blue-950 sm:row-span-2">
|
||||
{first ? <img src={first} alt={`${vehicle.year} ${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.noPhoto}</div>}
|
||||
</div>
|
||||
{rest.slice(0, 2).map((photo, index) => <div key={`${photo}-${index}`} className="aspect-[16/10] overflow-hidden rounded-2xl bg-stone-100 dark:bg-blue-900"><img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" /></div>)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950 sm:p-8">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">{copy.categories[vehicle.category as keyof typeof copy.categories] ?? vehicle.category}</p><h1 className="mt-2 text-3xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-4xl">{vehicle.make} {vehicle.model}</h1><p className="mt-2 text-stone-500 dark:text-slate-400">{vehicle.year}</p></div>
|
||||
<div className="text-start sm:text-end"><p className="text-xs text-stone-500 dark:text-slate-400">{copy.vehicle.from}</p><p className="text-2xl font-black text-blue-950 dark:text-white">{formatCurrency(vehicle.dailyRate, 'MAD', language)} <span className="text-sm font-semibold text-stone-500 dark:text-slate-400">{copy.vehicle.perDay}</span></p></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Spec icon={Gauge} label={copy.vehicle.transmission} value={vehicle.transmission} />
|
||||
<Spec icon={Users} label={copy.vehicle.seats} value={vehicle.seats ? String(vehicle.seats) : null} />
|
||||
<Spec icon={Fuel} label={copy.vehicle.fuel} value={vehicle.fuelType} />
|
||||
<Spec icon={ShieldCheck} label={copy.vehicle.category} value={copy.categories[vehicle.category as keyof typeof copy.categories] ?? vehicle.category} />
|
||||
</div>
|
||||
|
||||
{vehicle.features && vehicle.features.length > 0 ? <div className="mt-7 border-t border-stone-200 pt-6 dark:border-blue-900"><h2 className="text-sm font-black text-blue-950 dark:text-white">{copy.vehicle.features}</h2><div className="mt-3 flex flex-wrap gap-2">{vehicle.features.map((feature) => <span key={feature} className="carplace-spec">{feature}</span>)}</div></div> : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 md:grid-cols-2">
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-lg font-black text-blue-950 dark:text-white"><MapPin className="h-5 w-5 text-orange-600" />{copy.vehicle.pickupLocations}</h2><div className="mt-4 flex flex-wrap gap-2">{vehicle.pickupLocations.length > 0 ? vehicle.pickupLocations.map((location) => <span key={location} className="carplace-spec">{location}</span>) : <span className="text-sm text-stone-500 dark:text-slate-400">{vehicle.company.brand?.publicCity || '—'}</span>}</div></div>
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950"><h2 className="flex items-center gap-2 text-lg font-black text-blue-950 dark:text-white"><BadgeCheck className="h-5 w-5 text-emerald-600" />{companyName}</h2><div className="mt-4 flex flex-wrap gap-2">{vehicle.company.brand?.carplaceRating ? <span className="carplace-spec"><Star className="h-4 w-4 fill-current text-amber-500" />{vehicle.company.brand.carplaceRating.toFixed(1)}</span> : <span className="carplace-spec">{copy.vehicle.new}</span>}{vehicle.company.brand?.publicCity ? <span className="carplace-spec"><MapPin className="h-4 w-4" />{vehicle.company.brand.publicCity}</span> : null}</div><Link href={`${carplaceCompanyHref(companySlug)}${preserveQuery ? `?${preserveQuery}` : ''}`} className="carplace-secondary-button mt-5">{copy.actions.viewFleet}<ArrowLeft className="h-4 w-4 rotate-180 rtl:rotate-0" /></Link></div>
|
||||
</section>
|
||||
|
||||
{(vehicle.company.brand?.publicPhone || vehicle.company.brand?.whatsappNumber) ? <section className="rounded-[2rem] border border-stone-200 bg-white p-6 dark:border-blue-900 dark:bg-blue-950"><h2 className="text-lg font-black text-blue-950 dark:text-white">{copy.vehicle.directContact}</h2><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{companyName}</p><div className="mt-5 flex flex-wrap gap-3">{vehicle.company.brand.publicPhone ? <a className="carplace-secondary-button" href={`tel:${vehicle.company.brand.publicPhone}`}><Phone className="h-4 w-4" />{copy.vehicle.phone}</a> : null}{vehicle.company.brand.whatsappNumber ? <a className="carplace-primary-button" href={`https://wa.me/${vehicle.company.brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noreferrer"><MessageCircle className="h-4 w-4" />{copy.vehicle.whatsapp}</a> : null}</div></section> : null}
|
||||
</div>
|
||||
|
||||
<aside className="h-fit lg:sticky lg:top-24">
|
||||
<ProgressiveBookingFlow vehicleId={vehicle.id} companySlug={companySlug} vehicleName={`${vehicle.year} ${vehicle.make} ${vehicle.model}`} companyName={companyName} dailyRate={vehicle.dailyRate} pickupLocations={vehicle.pickupLocations} allowDifferentDropoff={vehicle.allowDifferentDropoff} dropoffLocations={vehicle.dropoffLocations} language={language} copy={copy} initial={initial} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{similar.length > 0 ? <section className="mt-16"><h2 className="text-3xl font-black tracking-[-0.035em] text-blue-950 dark:text-white">{copy.vehicle.similar}</h2><div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{similar.map((item) => <VehicleCard key={item.id} vehicle={item} copy={copy} language={language} query={preserveQuery} />)}</div></section> : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Spec({ icon: Icon, label, value }: { icon: typeof Gauge; label: string; value?: string | null }) { if (!value) return null; return <div className="rounded-2xl bg-stone-50 p-4 dark:bg-blue-900/30"><Icon className="h-5 w-5 text-orange-600 dark:text-orange-400" /><p className="mt-3 text-xs font-semibold text-stone-500 dark:text-slate-400">{label}</p><p className="mt-1 font-black text-blue-950 dark:text-white">{value}</p></div> }
|
||||
|
||||
function buildInitial(params: RawSearchParams): BookingInitialValues { const v = (key: string) => typeof params[key] === 'string' ? params[key] as string : undefined; return { pickupLocation: v('pickupLocation'), dropoffLocation: v('dropoffLocation'), pickupDate: v('pickupDate'), pickupTime: v('pickupTime'), returnDate: v('returnDate'), returnTime: v('returnTime'), promoCode: v('promoCode'), driverAge: v('driverAge') } }
|
||||
function buildPreserveQuery(params: RawSearchParams): string { const query = new URLSearchParams(); for (const key of ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode']) { const raw = params[key]; if (typeof raw === 'string' && raw) query.set(key, raw) } return query.toString() }
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import AppPrivacyEnPage from './app-privacy-en/page'
|
||||
import AppPrivacyFrPage from './app-privacy-fr/page'
|
||||
import AppPrivacyArPage from './app-privacy-ar/page'
|
||||
import AppTermsEnPage from './app-tc-en/page'
|
||||
import AppTermsFrPage from './app-tc-fr/page'
|
||||
import AppTermsArPage from './app-tc-ar/page'
|
||||
|
||||
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
const page = element as React.ReactElement<Record<string, unknown>>
|
||||
expect(page.type).toBe(FooterContentPage)
|
||||
expect(page.props.slug).toBe(slug)
|
||||
expect(page.props.forcedLanguage).toBe(forcedLanguage)
|
||||
}
|
||||
|
||||
describe('carplace static app policy pages', () => {
|
||||
it('binds app privacy pages to explicit locales', () => {
|
||||
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
|
||||
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
|
||||
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
|
||||
})
|
||||
|
||||
it('binds app terms pages to explicit locales and the app terms content slug', () => {
|
||||
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
|
||||
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
|
||||
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyArPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyEnPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyFrPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsArPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsEnPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsFrPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import FooterPage, { generateStaticParams } from './page'
|
||||
import { footerPageSlugs } from '@/lib/footerContent'
|
||||
|
||||
describe('carplace footer route', () => {
|
||||
it('generates one static route per registered footer slug', () => {
|
||||
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
|
||||
})
|
||||
|
||||
it('passes valid slugs through to FooterContentPage', () => {
|
||||
const element = FooterPage({ params: { slug: 'privacy-policy' } })
|
||||
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
expect(element.type).toBe(FooterContentPage)
|
||||
expect(element.props.slug).toBe('privacy-policy')
|
||||
expect(element.props.forcedLanguage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
|
||||
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
|
||||
expect(navigation.notFound).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return footerPageSlugs.map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
export default function FooterPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
const { slug } = params
|
||||
|
||||
if (!isFooterPageSlug(slug)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <FooterContentPage slug={slug} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import CarplaceFooter from '@/components/carplace/CarplaceFooter'
|
||||
import CarplaceHeader from '@/components/carplace/CarplaceHeader'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
const skipLabels = { en: 'Skip to content', fr: 'Aller au contenu', ar: 'الانتقال إلى المحتوى' } as const
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useCarplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col">
|
||||
<a href="#main-content" className="skip-link">{skipLabels[language]}</a>
|
||||
<CarplaceHeader dashboardUrl={dashboardUrl} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<CarplaceFooter dashboardUrl={dashboardUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ArrowRight, BadgeCheck, Building2, CalendarCheck2, CarFront, CircleDollarSign, FileCheck2, MapPin, SearchCheck, ShieldCheck, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import CarplaceSearchForm from '@/components/carplace/CarplaceSearchForm'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { formatOfferValue } from '@/lib/carplace/offers'
|
||||
import { carplaceCompanyHref, carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { CarplaceHomeResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
ECONOMY: CircleDollarSign,
|
||||
COMPACT: CarFront,
|
||||
MIDSIZE: CarFront,
|
||||
FULLSIZE: CarFront,
|
||||
SUV: ShieldCheck,
|
||||
LUXURY: Sparkles,
|
||||
VAN: Building2,
|
||||
TRUCK: Building2,
|
||||
} as const
|
||||
|
||||
export default async function CarplaceHomePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const fallback: CarplaceHomeResponse = { cities: [], offers: [], vehicles: [], companies: [] }
|
||||
const home = await carplaceFetchOrDefault<CarplaceHomeResponse>('/carplace/home', fallback)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="carplace-hero site-glow">
|
||||
<div className="shell grid gap-10 py-12 lg:grid-cols-[1.05fr_.95fr] lg:items-center lg:py-20">
|
||||
<div className="max-w-3xl">
|
||||
<p className="site-kicker">{copy.hero.eyebrow}</p>
|
||||
<h1 className="mt-5 text-4xl font-black leading-[1.03] tracking-[-0.045em] text-blue-950 dark:text-white sm:text-6xl">{copy.hero.title}</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg">{copy.hero.body}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3 text-sm font-semibold text-stone-700 dark:text-slate-200">
|
||||
<span className="carplace-trust-pill"><BadgeCheck className="h-4 w-4 text-emerald-600" />{copy.trust[0][0]}</span>
|
||||
<span className="carplace-trust-pill"><CalendarCheck2 className="h-4 w-4 text-blue-700 dark:text-blue-300" />{copy.trust[1][0]}</span>
|
||||
<span className="carplace-trust-pill"><FileCheck2 className="h-4 w-4 text-orange-600" />{copy.trust[2][0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-5 -z-10 rounded-[3rem] bg-gradient-to-br from-orange-300/30 via-transparent to-blue-400/25 blur-2xl" />
|
||||
<CarplaceSearchForm cities={home.cities} copy={copy} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-y border-stone-200 bg-white/80 dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<div className="shell grid gap-px py-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{copy.trust.map(([title, body], index) => {
|
||||
const Icon = [Building2, SearchCheck, BadgeCheck, CircleDollarSign][index] ?? ShieldCheck
|
||||
return <div key={title} className="flex gap-3 px-4 py-4"><span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-blue-50 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200"><Icon className="h-5 w-5" /></span><div><h2 className="text-sm font-black text-blue-950 dark:text-white">{title}</h2><p className="mt-1 text-xs leading-5 text-stone-500 dark:text-slate-400">{body}</p></div></div>
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.cities} />
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{home.cities.length > 0 ? home.cities.slice(0, 16).map((city) => (
|
||||
<Link key={city} href={`${carplaceHref('/search')}?pickupLocation=${encodeURIComponent(city)}`} className="carplace-city-link"><MapPin className="h-4 w-4" />{city}</Link>
|
||||
)) : <p className="text-sm text-stone-500 dark:text-slate-400">{copy.status.unavailableBody}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section pt-0">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.categories} />
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Object.entries(copy.categories).map(([value, label]) => {
|
||||
const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS] ?? CarFront
|
||||
return (
|
||||
<Link key={value} href={`${carplaceHref('/search')}?category=${value}`} className="carplace-category-card">
|
||||
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-blue-50 text-blue-900 dark:bg-blue-900/50 dark:text-blue-100"><Icon className="h-6 w-6" /></span>
|
||||
<span className="mt-5 flex items-center justify-between gap-3 text-lg font-black text-blue-950 dark:text-white">{label}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{home.offers.length > 0 ? (
|
||||
<section id="offers" className="site-section scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.deals} title={copy.sections.offers} />
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{home.offers.slice(0, 6).map((offer) => {
|
||||
const slug = offer.company.brand?.subdomain || offer.company.slug || ''
|
||||
return (
|
||||
<article key={offer.id} className="carplace-offer-card">
|
||||
<div className="flex items-start justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.16em] text-orange-200">{offer.company.brand?.displayName ?? copy.vehicle.company}</p><h3 className="mt-2 text-xl font-black text-white">{offer.title}</h3></div><span className="rounded-2xl bg-white px-3 py-2 text-2xl font-black text-blue-950">{formatOfferValue(offer, language)}</span></div>
|
||||
<p className="mt-6 text-sm text-blue-100">{new Intl.DateTimeFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-MA' : 'en-MA', { dateStyle: 'medium' }).format(new Date(offer.validUntil))}</p>
|
||||
{slug ? <Link href={carplaceCompanyHref(slug)} className="mt-5 inline-flex items-center gap-2 text-sm font-bold text-white no-underline">{copy.actions.viewFleet}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></Link> : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="site-section">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.vehicles} />
|
||||
<Link href={carplaceHref('/search')} className="carplace-secondary-button">{copy.actions.search}<ArrowRight className="h-4 w-4 rtl:rotate-180" /></Link>
|
||||
</div>
|
||||
{home.vehicles.length > 0 ? <div className="mt-6 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{home.vehicles.slice(0, 9).map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} />)}</div> : <EmptyState copy={copy.status.unavailableBody} />}
|
||||
</section>
|
||||
|
||||
<section id="companies" className="site-section scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.companies} title={copy.sections.companies} />
|
||||
{home.companies.length > 0 ? (
|
||||
<div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{home.companies.slice(0, 8).map((company) => {
|
||||
const slug = company.brand?.subdomain || company.slug || ''
|
||||
return (
|
||||
<Link key={company.id} href={carplaceCompanyHref(slug)} className="carplace-company-card">
|
||||
<div className="flex items-start justify-between gap-3"><span className="grid h-12 w-12 place-items-center rounded-2xl bg-blue-50 text-blue-900 dark:bg-blue-900/50 dark:text-blue-100"><Building2 className="h-6 w-6" /></span>{company.brand?.carplaceRating ? <span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950/50 dark:text-amber-200">★ {company.brand.carplaceRating.toFixed(1)}</span> : null}</div>
|
||||
<h3 className="mt-5 truncate text-lg font-black text-blue-950 dark:text-white">{company.brand?.displayName || company.name || copy.vehicle.company}</h3>
|
||||
<p className="mt-2 flex items-center gap-2 text-sm text-stone-500 dark:text-slate-400"><MapPin className="h-4 w-4" />{company.brand?.publicCity || copy.company.partner}</p>
|
||||
<p className="mt-4 text-sm font-semibold text-stone-700 dark:text-slate-200">{company._count.vehicles} {copy.company.vehicles}</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : <EmptyState copy={copy.status.unavailableBody} />}
|
||||
</section>
|
||||
|
||||
<section id="how-it-works" className="site-section scroll-mt-24">
|
||||
<div className="rounded-[2.5rem] bg-blue-950 p-7 text-white sm:p-10 lg:p-14">
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.how} inverse />
|
||||
<div className="mt-9 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{copy.how.map(([title, body], index) => <article key={title} className="relative rounded-2xl border border-blue-800 bg-blue-900/55 p-5"><span className="grid h-9 w-9 place-items-center rounded-full bg-orange-500 text-sm font-black text-white">{index + 1}</span><h3 className="mt-5 text-lg font-black">{title}</h3><p className="mt-2 text-sm leading-6 text-blue-100">{body}</p></article>)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="site-section grid gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<SectionHeading eyebrow="Carplace" title={copy.sections.guidance} />
|
||||
<div className="mt-6 grid gap-4">{copy.guidance.map(([title, body]) => <article key={title} className="carplace-guidance"><ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" /><div><h3 className="font-black text-blue-950 dark:text-white">{title}</h3><p className="mt-1 text-sm leading-6 text-stone-600 dark:text-slate-300">{body}</p></div></article>)}</div>
|
||||
</div>
|
||||
<div id="help" className="scroll-mt-24">
|
||||
<SectionHeading eyebrow={copy.nav.help} title={copy.sections.faq} />
|
||||
<div className="mt-6 grid gap-3">{copy.faq.map(([question, answer]) => <details key={question} className="carplace-faq"><summary>{question}</summary><p>{answer}</p></details>)}</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading({ eyebrow, title, inverse = false }: { eyebrow: string; title: string; inverse?: boolean }) {
|
||||
return <div><p className={`text-xs font-bold uppercase tracking-[0.2em] ${inverse ? 'text-orange-300' : 'text-orange-600 dark:text-orange-400'}`}>{eyebrow}</p><h2 className={`mt-3 text-3xl font-black tracking-[-0.035em] sm:text-4xl ${inverse ? 'text-white' : 'text-blue-950 dark:text-white'}`}>{title}</h2></div>
|
||||
}
|
||||
|
||||
function EmptyState({ copy }: { copy: string }) {
|
||||
return <div className="mt-6 rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500 dark:border-blue-800 dark:text-slate-400">{copy}</div>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import RequestSuccessClient from '@/components/carplace/RequestSuccessClient'
|
||||
|
||||
export default async function RequestSuccessPage({ params }: { params: Promise<{ reference: string }> }) {
|
||||
const { reference } = await params
|
||||
return <RequestSuccessClient reference={decodeURIComponent(reference)} />
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { SlidersHorizontal, SearchX } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import CarplaceSearchForm, { type CarplaceSearchValues } from '@/components/carplace/CarplaceSearchForm'
|
||||
import VehicleCard from '@/components/carplace/VehicleCard'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { carplaceHref } from '@/lib/carplace/routes'
|
||||
import type { VehicleSearchResponse } from '@/lib/carplace/types'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
|
||||
type RawSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export default async function CarplaceSearchPage({ searchParams }: { searchParams?: Promise<RawSearchParams> }) {
|
||||
const params = (await searchParams) ?? {}
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
const value = (key: string) => typeof params[key] === 'string' ? params[key] as string : ''
|
||||
|
||||
const initial: CarplaceSearchValues = {
|
||||
pickupLocation: value('pickupLocation') || value('city'),
|
||||
dropoffLocation: value('dropoffLocation'),
|
||||
dropoffMode: value('dropoffMode') === 'different' ? 'different' : 'same',
|
||||
pickupDate: value('pickupDate'),
|
||||
pickupTime: value('pickupTime') || '10:00',
|
||||
returnDate: value('returnDate'),
|
||||
returnTime: value('returnTime') || '10:00',
|
||||
driverAge: value('driverAge') || '25',
|
||||
promoCode: value('promoCode'),
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(value('page') || '1') || 1)
|
||||
const pageSize = 18
|
||||
const apiQuery = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (initial.pickupLocation) { apiQuery.set('city', initial.pickupLocation); apiQuery.set('pickupLocation', initial.pickupLocation) }
|
||||
if (initial.dropoffMode) apiQuery.set('dropoffMode', initial.dropoffMode)
|
||||
if (initial.dropoffMode === 'different' && initial.dropoffLocation) apiQuery.set('dropoffLocation', initial.dropoffLocation)
|
||||
const startDate = toIso(initial.pickupDate, initial.pickupTime)
|
||||
const endDate = toIso(initial.returnDate, initial.returnTime)
|
||||
if (startDate) apiQuery.set('startDate', startDate)
|
||||
if (endDate) apiQuery.set('endDate', endDate)
|
||||
for (const key of ['category', 'transmission', 'make', 'model'] as const) if (value(key)) apiQuery.set(key, value(key))
|
||||
const maxPriceMad = Number(value('maxPrice'))
|
||||
if (Number.isFinite(maxPriceMad) && maxPriceMad > 0) apiQuery.set('maxPrice', String(Math.round(maxPriceMad * 100)))
|
||||
|
||||
const fallback: VehicleSearchResponse = { items: [], pagination: { page, pageSize, totalItems: 0, totalPages: 0 }, facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } } }
|
||||
const [cities, response] = await Promise.all([
|
||||
carplaceFetchOrDefault<string[]>('/carplace/cities', []),
|
||||
carplaceFetchOrDefault<VehicleSearchResponse>(`/carplace/search?${apiQuery.toString()}`, fallback),
|
||||
])
|
||||
|
||||
const sort = value('sort') || 'price-asc'
|
||||
const items = [...response.items].sort((a, b) => sort === 'price-desc' ? b.dailyRate - a.dailyRate : a.dailyRate - b.dailyRate)
|
||||
const cardQuery = buildCardQuery(params)
|
||||
|
||||
return (
|
||||
<main className="site-page" id="main-content">
|
||||
<section className="border-b border-stone-200 bg-white/85 py-6 dark:border-blue-900 dark:bg-blue-950/80">
|
||||
<div className="shell"><CarplaceSearchForm cities={cities} copy={copy} initial={initial} compact /></div>
|
||||
</section>
|
||||
|
||||
<div className="shell grid gap-8 py-10 lg:grid-cols-[270px_1fr]">
|
||||
<aside className="h-fit rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950 lg:sticky lg:top-24">
|
||||
<h2 className="flex items-center gap-2 text-lg font-black text-blue-950 dark:text-white"><SlidersHorizontal className="h-5 w-5" />{copy.sections.filters}</h2>
|
||||
<form action={carplaceHref('/search')} method="get" className="mt-5 grid gap-4">
|
||||
{preservedTripInputs(params)}
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.vehicle.category}<select name="category" defaultValue={value('category')} className="carplace-input"><option value="">{copy.results.allCategories}</option>{Object.entries(copy.categories).map(([category, label]) => <option key={category} value={category}>{label}</option>)}</select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.maxPrice}<input type="number" min="0" name="maxPrice" defaultValue={value('maxPrice')} className="carplace-input" inputMode="numeric" /></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.transmission}<select name="transmission" defaultValue={value('transmission')} className="carplace-input"><option value="">—</option>{response.facets.transmissions.map((facet) => <option key={facet.value} value={facet.value}>{facet.value} ({facet.count})</option>)}</select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.make}<input name="make" defaultValue={value('make')} className="carplace-input" list="carplace-makes" /><datalist id="carplace-makes">{response.facets.makes.map((facet) => <option key={facet.value} value={facet.value} />)}</datalist></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.model}<input name="model" defaultValue={value('model')} className="carplace-input" /></label>
|
||||
<button className="carplace-primary-button justify-center" type="submit">{copy.results.apply}</button>
|
||||
<Link href={carplaceHref('/search')} className="carplace-secondary-button justify-center">{copy.results.clear}</Link>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-600 dark:text-orange-400">Carplace</p><h1 className="mt-2 text-3xl font-black tracking-[-0.035em] text-blue-950 dark:text-white">{copy.results.title}</h1><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{response.pagination.totalItems} {copy.results.count}</p></div>
|
||||
<form action={carplaceHref('/search')} method="get" className="flex items-end gap-2">
|
||||
{Object.entries(params).flatMap(([key, raw]) => key === 'sort' || key === 'page' ? [] : (Array.isArray(raw) ? raw : [raw]).filter((entry): entry is string => typeof entry === 'string').map((entry) => <input key={`${key}-${entry}`} type="hidden" name={key} value={entry} />))}
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.results.sort}<select name="sort" defaultValue={sort} className="carplace-input"><option value="price-asc">{copy.results.sortPriceAsc}</option><option value="price-desc">{copy.results.sortPriceDesc}</option></select></label>
|
||||
<button type="submit" className="carplace-secondary-button">{copy.results.apply}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? <div className="mt-7 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{items.map((vehicle) => <VehicleCard key={vehicle.id} vehicle={vehicle} copy={copy} language={language} query={cardQuery} />)}</div> : <div className="mt-8 rounded-[2rem] border border-dashed border-stone-300 p-10 text-center dark:border-blue-800"><SearchX className="mx-auto h-10 w-10 text-stone-400" /><h2 className="mt-4 text-xl font-black text-blue-950 dark:text-white">{copy.results.none}</h2><p className="mt-2 text-sm text-stone-500 dark:text-slate-400">{copy.results.noneBody}</p></div>}
|
||||
|
||||
{response.pagination.totalPages > 1 ? <Pagination current={response.pagination.page} total={response.pagination.totalPages} params={params} previous={copy.results.previous} next={copy.results.next} label={copy.accessibility.pagination} /> : null}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function toIso(date?: string, time?: string): string | null { if (!date || !time) return null; const result = new Date(`${date}T${time}:00`); return Number.isNaN(result.getTime()) ? null : result.toISOString() }
|
||||
|
||||
function buildCardQuery(params: RawSearchParams): URLSearchParams {
|
||||
const query = new URLSearchParams()
|
||||
for (const key of ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode'] as const) {
|
||||
const raw = params[key]
|
||||
if (typeof raw === 'string' && raw) query.set(key, raw)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function preservedTripInputs(params: RawSearchParams) {
|
||||
return ['pickupLocation', 'dropoffLocation', 'dropoffMode', 'pickupDate', 'pickupTime', 'returnDate', 'returnTime', 'driverAge', 'promoCode'].flatMap((key) => {
|
||||
const raw = params[key]
|
||||
return (Array.isArray(raw) ? raw : [raw]).filter((entry): entry is string => typeof entry === 'string').map((entry) => <input key={`${key}-${entry}`} type="hidden" name={key} value={entry} />)
|
||||
})
|
||||
}
|
||||
|
||||
function Pagination({ current, total, params, previous, next, label }: { current: number; total: number; params: RawSearchParams; previous: string; next: string; label: string }) {
|
||||
const href = (page: number) => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, raw]) => { if (key === 'page') return; (Array.isArray(raw) ? raw : [raw]).forEach((entry) => { if (typeof entry === 'string') query.append(key, entry) }) }); query.set('page', String(page)); return `${carplaceHref('/search')}?${query.toString()}` }
|
||||
return <nav className="mt-10 flex items-center justify-center gap-3" aria-label={label}>{current > 1 ? <Link href={href(current - 1)} className="carplace-secondary-button">{previous}</Link> : null}<span className="text-sm font-bold text-stone-600 dark:text-slate-300">{current} / {total}</span>{current < total ? <Link href={href(current + 1)} className="carplace-secondary-button">{next}</Link> : null}</nav>
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #fafafa 28%, #f5f5f5 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #172554 0%, #1b3068 35%, #0f1a40 100%);
|
||||
}
|
||||
|
||||
.site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
html.dark .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(30, 64, 175, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
.site-section {
|
||||
@apply shell py-10 sm:py-14 lg:py-16;
|
||||
}
|
||||
|
||||
.site-panel {
|
||||
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .site-panel {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-color: rgb(23 37 84 / 0.72);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-image: linear-gradient(160deg, #fafafa 0%, #f5f5f5 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 64 175 / 0.40);
|
||||
background-image: linear-gradient(160deg, #172554 0%, #1b3068 100%);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
|
||||
border-color: rgb(20 45 95 / 0.8);
|
||||
background-color: #172554;
|
||||
}
|
||||
|
||||
html.dark .site-panel-contrast {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
|
||||
}
|
||||
|
||||
.site-lead {
|
||||
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
|
||||
}
|
||||
|
||||
.site-link-primary {
|
||||
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.site-link-secondary {
|
||||
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-[#2e50f1] dark:bg-[#2e50f1]/30 dark:text-[#1e3de0] dark:hover:border-[#1e3de0] dark:hover:text-white;
|
||||
}
|
||||
|
||||
/* Carplace public experience */
|
||||
html {
|
||||
min-width: 320px;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 96px;
|
||||
}
|
||||
|
||||
html[lang='ar'] body {
|
||||
font-family: 'Noto Sans Arabic', Tahoma, Arial, sans-serif;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset-block-start: .5rem;
|
||||
inset-inline-start: .5rem;
|
||||
transform: translateY(-160%);
|
||||
border-radius: .75rem;
|
||||
background: #172554;
|
||||
color: white;
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 800;
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
|
||||
.skip-link:focus { transform: translateY(0); }
|
||||
|
||||
.carplace-header {
|
||||
position: sticky;
|
||||
z-index: 100;
|
||||
inset-block-start: 0;
|
||||
border-bottom: 1px solid rgb(231 229 228 / .9);
|
||||
background: rgb(255 255 255 / .94);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / .06);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
html.dark .carplace-header {
|
||||
border-color: rgb(30 64 175 / .55);
|
||||
background: rgb(23 37 84 / .94);
|
||||
}
|
||||
|
||||
.carplace-control,
|
||||
.carplace-icon-button,
|
||||
.carplace-company-link,
|
||||
.carplace-primary-button,
|
||||
.carplace-secondary-button,
|
||||
.carplace-light-button,
|
||||
.carplace-orange-button {
|
||||
@apply inline-flex min-h-11 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-bold no-underline transition disabled:cursor-not-allowed disabled:opacity-60;
|
||||
}
|
||||
|
||||
.carplace-control,
|
||||
.carplace-icon-button,
|
||||
.carplace-secondary-button {
|
||||
@apply border border-stone-300 bg-white text-stone-700 hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-slate-200 dark:hover:border-blue-500 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.carplace-icon-button { @apply min-w-11 justify-center px-0; }
|
||||
.carplace-company-link { @apply bg-blue-950 text-white hover:bg-blue-900 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-primary-button,
|
||||
.carplace-orange-button { @apply bg-orange-600 text-white hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-light-button { @apply bg-white text-blue-950 hover:bg-blue-50; }
|
||||
|
||||
.carplace-menu {
|
||||
@apply absolute top-full z-50 mt-2 w-60 overflow-hidden rounded-2xl border border-stone-200 bg-white p-1 shadow-2xl dark:border-blue-800 dark:bg-blue-950;
|
||||
}
|
||||
|
||||
.carplace-menu-item { @apply flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-start text-sm font-semibold text-stone-700 hover:bg-blue-50 dark:text-slate-200 dark:hover:bg-blue-900/50; }
|
||||
|
||||
.carplace-hero {
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 10%, rgb(249 115 22 / .16), transparent 28%),
|
||||
radial-gradient(circle at 92% 8%, rgb(37 99 235 / .14), transparent 28%),
|
||||
linear-gradient(180deg, #fff, #f8fafc);
|
||||
}
|
||||
|
||||
html.dark .carplace-hero {
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 10%, rgb(249 115 22 / .18), transparent 28%),
|
||||
radial-gradient(circle at 92% 8%, rgb(59 130 246 / .16), transparent 28%),
|
||||
linear-gradient(180deg, #172554, #0f1a40);
|
||||
}
|
||||
|
||||
.carplace-search {
|
||||
@apply rounded-[2rem] border border-stone-200 bg-white/95 p-5 shadow-[0_30px_90px_rgba(15,23,42,0.14)] backdrop-blur dark:border-blue-800 dark:bg-blue-950/95 sm:p-6;
|
||||
}
|
||||
|
||||
.carplace-search-compact {
|
||||
@apply rounded-2xl p-4 shadow-none;
|
||||
}
|
||||
|
||||
.carplace-input {
|
||||
@apply min-h-11 w-full rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm font-medium text-blue-950 outline-none transition placeholder:text-stone-400 hover:border-blue-500 focus:border-blue-700 focus:ring-2 focus:ring-blue-200 dark:border-blue-800 dark:bg-blue-950 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-900;
|
||||
}
|
||||
|
||||
.carplace-choice { @apply cursor-pointer rounded-full border border-stone-300 bg-white px-3 py-2 text-xs font-bold text-stone-600 transition dark:border-blue-800 dark:bg-blue-950 dark:text-slate-300; }
|
||||
.carplace-choice-active { @apply border-orange-500 bg-orange-50 text-orange-800 dark:bg-orange-950/40 dark:text-orange-200; }
|
||||
.carplace-trust-pill { @apply inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white/85 px-3 py-2 shadow-sm dark:border-blue-800 dark:bg-blue-950/70; }
|
||||
.carplace-dark-pill { @apply inline-flex items-center gap-2 rounded-full border border-blue-700 bg-blue-900/70 px-3 py-2; }
|
||||
.carplace-spec { @apply inline-flex items-center gap-1.5 rounded-full bg-stone-100 px-3 py-1.5 dark:bg-blue-900/45; }
|
||||
|
||||
.carplace-city-link { @apply inline-flex items-center gap-2 rounded-full border border-stone-200 bg-white px-4 py-2.5 text-sm font-bold text-stone-700 no-underline shadow-sm transition hover:-translate-y-0.5 hover:border-orange-400 hover:text-orange-700 dark:border-blue-800 dark:bg-blue-950 dark:text-slate-200 dark:hover:border-orange-500 dark:hover:text-orange-300; }
|
||||
.carplace-category-card,
|
||||
.carplace-company-card { @apply rounded-[1.5rem] border border-stone-200 bg-white p-5 no-underline shadow-[0_14px_45px_rgba(15,23,42,0.06)] transition hover:-translate-y-1 hover:border-blue-300 hover:shadow-[0_20px_60px_rgba(15,23,42,0.12)] dark:border-blue-900 dark:bg-blue-950 dark:hover:border-blue-600; }
|
||||
.carplace-offer-card { @apply rounded-[2rem] bg-gradient-to-br from-blue-950 to-blue-800 p-6 shadow-[0_24px_70px_rgba(15,23,42,0.2)]; }
|
||||
.carplace-guidance { @apply flex gap-3 rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950; }
|
||||
.carplace-faq { @apply rounded-2xl border border-stone-200 bg-white p-5 dark:border-blue-900 dark:bg-blue-950; }
|
||||
.carplace-faq summary { @apply cursor-pointer font-black text-blue-950 dark:text-white; }
|
||||
.carplace-faq p { @apply mb-0 mt-3 text-sm leading-7 text-stone-600 dark:text-slate-300; }
|
||||
|
||||
.carplace-vehicle-card { @apply flex min-w-0 flex-col overflow-hidden rounded-[1.75rem] border border-stone-200 bg-white shadow-[0_18px_60px_rgba(15,23,42,0.08)] transition hover:-translate-y-1 hover:border-blue-300 hover:shadow-[0_24px_70px_rgba(15,23,42,0.14)] dark:border-blue-900 dark:bg-blue-950 dark:hover:border-blue-600; }
|
||||
.carplace-card-action { @apply grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-blue-950 text-white no-underline transition hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-400; }
|
||||
.carplace-booking { @apply rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_70px_rgba(15,23,42,0.12)] dark:border-blue-800 dark:bg-blue-950; }
|
||||
.carplace-step { @apply flex min-w-0 flex-col items-center gap-1 text-center text-[10px] font-bold text-stone-400; }
|
||||
.carplace-step span { @apply grid h-7 w-7 place-items-center rounded-full border border-stone-300 bg-white dark:border-blue-800 dark:bg-blue-950; }
|
||||
.carplace-step-active { @apply text-orange-700 dark:text-orange-300; }
|
||||
.carplace-step-active span { @apply border-orange-500 bg-orange-50 dark:bg-orange-950/50; }
|
||||
.carplace-step-done { @apply text-emerald-700 dark:text-emerald-300; }
|
||||
.carplace-step-done span { @apply border-emerald-500 bg-emerald-50 dark:bg-emerald-950/50; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import CarplaceShell from '@/components/CarplaceShell'
|
||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const language = await getCarplaceLanguage()
|
||||
const copy = getCarplaceMessages(language)
|
||||
return {
|
||||
title: `${copy.brand} by RentalDriveGo`,
|
||||
description: copy.hero.body,
|
||||
alternates: { canonical: '/carplace' },
|
||||
openGraph: {
|
||||
title: `${copy.brand} by RentalDriveGo`,
|
||||
description: copy.hero.body,
|
||||
type: 'website',
|
||||
locale: language === 'ar' ? 'ar_MA' : language === 'fr' ? 'fr_MA' : 'en_MA',
|
||||
siteName: 'RentalDriveGo',
|
||||
},
|
||||
icons: {
|
||||
icon: '/rentaldrivego.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentaldrivego.png',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):localStorage.getItem('rentaldrivego-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<CarplaceShell initialLanguage={language} initialTheme={theme}>{children}</CarplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user