This commit is contained in:
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,74 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
|
||||
|
||||
// The carplace proxies /dashboard and /admin to their own Next dev servers.
|
||||
// Allow those asset-prefix origins so proxied pages can load chunks and HMR.
|
||||
const dashboardAssetSource = normalizeAssetPrefix(
|
||||
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
|
||||
'/dashboard',
|
||||
)
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX],
|
||||
})
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'res.cloudinary.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
]
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/dashboard/dashboard',
|
||||
destination: '/dashboard',
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: '/dashboard/dashboard/:path*',
|
||||
destination: '/dashboard/:path*',
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001'
|
||||
const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002'
|
||||
|
||||
return [
|
||||
// Dashboard has basePath '/dashboard'. The proxy preserves the prefix
|
||||
// so the dashboard can strip it and route internally.
|
||||
{
|
||||
source: '/dashboard',
|
||||
destination: `${dashboardOrigin}/dashboard`,
|
||||
},
|
||||
{
|
||||
source: '/dashboard/:path*',
|
||||
destination: `${dashboardOrigin}/dashboard/:path*`,
|
||||
},
|
||||
// Admin routes (also uses basePath)
|
||||
{
|
||||
source: '/admin',
|
||||
destination: `${adminOrigin}/admin`,
|
||||
},
|
||||
{
|
||||
source: '/admin/:path*',
|
||||
destination: `${adminOrigin}/admin/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "@rentaldrivego/carplace",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||
"dev": "next dev -H 0.0.0.0 -p 3004",
|
||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||
"build": "next build",
|
||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||
"pretype-check": "npm run build --workspace @rentaldrivego/types",
|
||||
"start": "next start -H 0.0.0.0 -p 3004",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rentaldrivego/types": "*",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "^16.2.7",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,14 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#0f172a" />
|
||||
<text
|
||||
x="16"
|
||||
y="21"
|
||||
text-anchor="middle"
|
||||
font-family="Inter, Arial, sans-serif"
|
||||
font-size="18"
|
||||
font-weight="700"
|
||||
fill="#ffffff"
|
||||
>
|
||||
R
|
||||
</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 302 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 836 KiB |
@@ -1,31 +0,0 @@
|
||||
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
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyArPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyEnPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyFrPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsArPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsEnPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsFrPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CompanyWorkspacePage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
type ExploreSearchFormDict = {
|
||||
searchTitle: string
|
||||
pickupLocation: string
|
||||
dropoffLocation: string
|
||||
sameDropoff: string
|
||||
differentDropoff: string
|
||||
sameAsPickup: string
|
||||
pickupDate: string
|
||||
returnDate: string
|
||||
hour: string
|
||||
promoCode: string
|
||||
myAge: string
|
||||
ageDescription: string
|
||||
search: string
|
||||
ageOptions: string[]
|
||||
}
|
||||
|
||||
function toUtcDateTime(date: string, time: string) {
|
||||
if (!date) return ''
|
||||
return `${date}T${time || '10:00'}:00.000Z`
|
||||
}
|
||||
|
||||
const inputBase = 'w-full border-0 bg-white dark:bg-blue-900/30 px-4 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none placeholder:text-stone-400 dark:placeholder:text-stone-500'
|
||||
const selectBase = 'w-full border-0 bg-white dark:bg-blue-900/30 py-4 text-sm text-stone-900 dark:text-stone-100 outline-none'
|
||||
|
||||
export default function ExploreSearchForm({
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
dropoffMode,
|
||||
pickupDate,
|
||||
pickupTime,
|
||||
returnDate,
|
||||
returnTime,
|
||||
promoCode,
|
||||
driverAge,
|
||||
cities,
|
||||
dict,
|
||||
}: {
|
||||
pickupLocation: string
|
||||
dropoffLocation: string
|
||||
dropoffMode: 'same' | 'different'
|
||||
pickupDate: string
|
||||
pickupTime: string
|
||||
returnDate: string
|
||||
returnTime: string
|
||||
promoCode: string
|
||||
driverAge: string
|
||||
cities: string[]
|
||||
dict: ExploreSearchFormDict
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const cityOptions = Array.isArray(cities) ? cities : []
|
||||
const ageOptions = Array.isArray(dict.ageOptions) ? dict.ageOptions : []
|
||||
const [filters, setFilters] = useState({
|
||||
pickupLocation,
|
||||
dropoffLocation,
|
||||
dropoffMode,
|
||||
pickupDate,
|
||||
pickupTime: pickupTime || '10:00',
|
||||
returnDate,
|
||||
returnTime: returnTime || '10:00',
|
||||
promoCode,
|
||||
driverAge: driverAge || '25+',
|
||||
})
|
||||
const [ageHelpOpen, setAgeHelpOpen] = useState(false)
|
||||
|
||||
function updateFilter(name: keyof typeof filters, value: string) {
|
||||
setFilters((current) => ({ ...current, [name]: value }))
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const query = new URLSearchParams()
|
||||
const normalizedPickup = filters.pickupLocation.trim()
|
||||
const normalizedDropoff = filters.dropoffMode === 'different'
|
||||
? filters.dropoffLocation.trim()
|
||||
: normalizedPickup
|
||||
const normalizedPromo = filters.promoCode.trim()
|
||||
|
||||
if (normalizedPickup) {
|
||||
query.set('pickupLocation', normalizedPickup)
|
||||
query.set('city', normalizedPickup)
|
||||
}
|
||||
query.set('dropoffMode', filters.dropoffMode)
|
||||
if (filters.dropoffMode === 'different' && normalizedDropoff) {
|
||||
query.set('dropoffLocation', normalizedDropoff)
|
||||
}
|
||||
if (filters.pickupDate) query.set('pickupDate', filters.pickupDate)
|
||||
if (filters.pickupTime) query.set('pickupTime', filters.pickupTime)
|
||||
if (filters.returnDate) query.set('returnDate', filters.returnDate)
|
||||
if (filters.returnTime) query.set('returnTime', filters.returnTime)
|
||||
if (normalizedPromo) query.set('promoCode', normalizedPromo)
|
||||
if (filters.driverAge) query.set('driverAge', filters.driverAge)
|
||||
|
||||
const startDateTime = toUtcDateTime(filters.pickupDate, filters.pickupTime)
|
||||
const endDateTime = toUtcDateTime(filters.returnDate, filters.returnTime)
|
||||
if (startDateTime) query.set('startDate', startDateTime)
|
||||
if (endDateTime) query.set('endDate', endDateTime)
|
||||
|
||||
const target = query.toString() ? `/explore?${query.toString()}` : '/explore'
|
||||
router.push(target)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-[1.75rem] bg-white dark:bg-blue-950 p-5 text-stone-900 dark:text-stone-100 shadow-[0_24px_80px_rgba(0,0,0,0.16)]">
|
||||
<h2 className="text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.searchTitle}</h2>
|
||||
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
||||
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<select
|
||||
name="pickupLocation"
|
||||
value={filters.pickupLocation}
|
||||
onChange={(event) => updateFilter('pickupLocation', event.target.value)}
|
||||
className={selectBase}
|
||||
>
|
||||
<option value="">{dict.pickupLocation}</option>
|
||||
{cityOptions.map((city) => (
|
||||
<option key={`pickup-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="bg-white dark:bg-blue-900/30 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.dropoffLocation}</span>
|
||||
<div className="inline-flex rounded-full border border-stone-200 dark:border-blue-800 bg-stone-100 dark:bg-blue-950 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'same', dropoffLocation: '' }))}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
filters.dropoffMode === 'same'
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-500 dark:text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{dict.sameDropoff}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'different' }))}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
filters.dropoffMode === 'different'
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-500 dark:text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{dict.differentDropoff}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{filters.dropoffMode === 'different' ? (
|
||||
<label className="mt-3 flex items-center gap-3">
|
||||
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<select
|
||||
name="dropoffLocation"
|
||||
value={filters.dropoffLocation}
|
||||
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
|
||||
className={selectBase}
|
||||
>
|
||||
<option value="">{dict.dropoffLocation}</option>
|
||||
{cityOptions.map((city) => (
|
||||
<option key={`dropoff-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-2xl bg-stone-50 px-4 py-4 text-sm font-medium text-stone-500 dark:bg-blue-950 dark:text-stone-300">
|
||||
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<span>{filters.pickupLocation || dict.sameAsPickup}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-4">
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.pickupDate}</span>
|
||||
<input
|
||||
name="pickupDate"
|
||||
type="date"
|
||||
value={filters.pickupDate}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(event) => updateFilter('pickupDate', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
|
||||
<input
|
||||
name="pickupTime"
|
||||
type="time"
|
||||
value={filters.pickupTime}
|
||||
onChange={(event) => updateFilter('pickupTime', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<CalendarDays className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.returnDate}</span>
|
||||
<input
|
||||
name="returnDate"
|
||||
type="date"
|
||||
value={filters.returnDate}
|
||||
min={filters.pickupDate || new Date().toISOString().slice(0, 10)}
|
||||
onChange={(event) => updateFilter('returnDate', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-blue-900/30 px-4">
|
||||
<Clock3 className="h-5 w-5 text-stone-500 dark:text-stone-400" />
|
||||
<div className="flex w-full flex-col py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.hour}</span>
|
||||
<input
|
||||
name="returnTime"
|
||||
type="time"
|
||||
value={filters.returnTime}
|
||||
onChange={(event) => updateFilter('returnTime', event.target.value)}
|
||||
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 dark:text-stone-100 outline-none [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid gap-px overflow-visible rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 lg:grid-cols-[1.1fr_1.1fr_1.35fr]">
|
||||
<label className="flex items-center gap-3 rounded-t-[1.35rem] bg-white dark:bg-blue-900/30 px-4 lg:rounded-l-[1.35rem] lg:rounded-tr-none">
|
||||
<TicketPercent className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<input
|
||||
name="promoCode"
|
||||
value={filters.promoCode}
|
||||
onChange={(event) => updateFilter('promoCode', event.target.value)}
|
||||
placeholder={dict.promoCode}
|
||||
className={`${inputBase} pl-2`}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3 bg-white dark:bg-blue-900/30 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="text-sm font-semibold text-stone-600 dark:text-stone-300">{dict.myAge}</span>
|
||||
<select
|
||||
name="driverAge"
|
||||
value={filters.driverAge}
|
||||
onChange={(event) => updateFilter('driverAge', event.target.value)}
|
||||
className="border-0 bg-transparent text-sm font-semibold text-stone-900 dark:text-stone-100 outline-none"
|
||||
>
|
||||
{ageOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
onMouseEnter={() => setAgeHelpOpen(true)}
|
||||
onMouseLeave={() => setAgeHelpOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={dict.ageDescription}
|
||||
onClick={() => setAgeHelpOpen((open) => !open)}
|
||||
className="text-stone-400 dark:text-stone-500 transition hover:text-stone-600 dark:hover:text-stone-300"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
{ageHelpOpen ? (
|
||||
<div className="absolute bottom-[calc(100%+0.85rem)] right-0 z-30 w-72 rounded-2xl border border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950 px-4 py-3 text-left text-sm leading-6 text-stone-700 dark:text-stone-200 shadow-[0_16px_40px_rgba(0,0,0,0.14)]">
|
||||
{dict.ageDescription}
|
||||
<span className="absolute right-6 top-full h-3 w-3 -translate-y-1/2 rotate-45 border-b border-r border-orange-300 dark:border-orange-600 bg-white dark:bg-blue-950" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="min-h-[76px] rounded-b-[1.35rem] bg-orange-600 px-6 text-sm font-black uppercase tracking-[0.08em] text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 lg:rounded-b-none lg:rounded-r-[1.35rem]"
|
||||
>
|
||||
{dict.search}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency, type Locale } from '@rentaldrivego/types'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: {
|
||||
slug: string
|
||||
brand: {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
subdomain: string
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
interface Dict {
|
||||
rentalCompany: string
|
||||
checkAvailability: string
|
||||
available: string
|
||||
reserved: string
|
||||
maintenance: string
|
||||
unavailableStatus: string
|
||||
from: string
|
||||
viewVehicle: string
|
||||
availableFrom: string
|
||||
unavailableNoDate: string
|
||||
vehicleUnavailable: string
|
||||
listings: string
|
||||
availableVehicles: string
|
||||
}
|
||||
|
||||
function formatCents(cents: number, language: Locale) {
|
||||
return formatCurrency(cents, 'MAD', language)
|
||||
}
|
||||
|
||||
function formatAvailabilityDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
function getStatusCopy(vehicle: Vehicle, dict: Dict) {
|
||||
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
|
||||
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
|
||||
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
|
||||
if (vehicle.availability === false) return dict.checkAvailability
|
||||
return dict.available
|
||||
}
|
||||
|
||||
export default function ExploreVehicleGrid({ vehicles, dict, language }: { vehicles: Vehicle[]; dict: Dict; language: Locale }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.availableVehicles}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicles.length} {dict.listings}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card">
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-t-[2rem] bg-stone-100 dark:bg-blue-900/30">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h3 className="mt-2 truncate text-lg font-bold text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 truncate text-sm text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400'}`}>
|
||||
{getStatusCopy(vehicle, dict)}
|
||||
</span>
|
||||
</div>
|
||||
{vehicle.availability === false && (
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.from}</p>
|
||||
<p className="truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCents(vehicle.dailyRate, language)}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`}
|
||||
className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.viewVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{vehicles.length === 0 && (
|
||||
<div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface CompanyProfile {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
brand: {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
publicCity: string | null
|
||||
publicCountry: string | null
|
||||
publicPhone: string | null
|
||||
whatsappNumber: string | null
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string; availability: boolean; nextAvailableAt: string | null }>
|
||||
offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }>
|
||||
}
|
||||
|
||||
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Carplace unavailable',
|
||||
unavailableTitle: 'Company details are temporarily unavailable.',
|
||||
unavailableBody: 'The carplace API is running, but the database is not reachable in this local environment.',
|
||||
backToExplore: 'Back to explore',
|
||||
partner: 'Carplace partner',
|
||||
vehicles: 'vehicles',
|
||||
offers: 'active offers',
|
||||
rating: 'Rating',
|
||||
new: 'New',
|
||||
currentOffers: 'Current offers',
|
||||
validUntil: 'Valid until',
|
||||
noOffers: 'No active offers right now.',
|
||||
fleet: 'Fleet',
|
||||
viewVehicle: 'View vehicle',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'Temporarily unavailable for new reservations.',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Carplace indisponible',
|
||||
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
|
||||
unavailableBody: "L'API carplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
||||
backToExplore: "Retour à l'exploration",
|
||||
partner: 'Partenaire carplace',
|
||||
vehicles: 'véhicules',
|
||||
offers: 'offres actives',
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
currentOffers: 'Offres actuelles',
|
||||
validUntil: "Valable jusqu'au",
|
||||
noOffers: 'Aucune offre active pour le moment.',
|
||||
fleet: 'Flotte',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Temporairement indisponible pour les nouvelles réservations.',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
partner: 'شريك في السوق',
|
||||
vehicles: 'مركبات',
|
||||
offers: 'عروض نشطة',
|
||||
rating: 'التقييم',
|
||||
new: 'جديد',
|
||||
currentOffers: 'العروض الحالية',
|
||||
validUntil: 'صالح حتى',
|
||||
noOffers: 'لا توجد عروض نشطة حالياً.',
|
||||
fleet: 'الأسطول',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
},
|
||||
}[language]
|
||||
const company = await carplaceFetchOrDefault<CompanyProfile | null>(`/carplace/${params.slug}`, null)
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{dict.unavailable}</p>
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/explore" className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToExplore}</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-8">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{company.brand?.publicCity ?? dict.partner}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? company.name}</h1>
|
||||
<p className="mt-3 text-stone-600 dark:text-stone-400">{company.brand?.publicCountry ?? ''}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-stone-500 dark:text-stone-400">
|
||||
<span>{company.vehicles.length} {dict.vehicles}</span>
|
||||
<span>{company.offers.length} {dict.offers}</span>
|
||||
<span>{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentOffers}</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{company.offers.map((offer) => (
|
||||
<div key={offer.id} className="card p-5">
|
||||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{offer.title}</p>
|
||||
<p className="mt-2 text-3xl font-black text-orange-700 dark:text-orange-400">{offer.discountValue}%</p>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
|
||||
</div>
|
||||
))}
|
||||
{company.offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400">{dict.noOffers}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.fleet}</h2>
|
||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{company.vehicles.map((vehicle) => (
|
||||
<article key={vehicle.id} className="card">
|
||||
<div className="aspect-[16/10] overflow-hidden rounded-t-[2rem] bg-stone-100 dark:bg-blue-900/30">
|
||||
{vehicle.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="truncate text-lg font-bold text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 truncate text-sm text-stone-500 dark:text-stone-400">{vehicle.category}</p>
|
||||
{!vehicle.availability && (
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-end justify-between gap-3">
|
||||
<p className="min-w-0 truncate text-xl font-black text-stone-900 dark:text-stone-100">{formatCurrency(vehicle.dailyRate, 'MAD', language)}</p>
|
||||
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="shrink-0 rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.viewVehicle}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import BookingForm from '@/components/BookingForm'
|
||||
|
||||
interface VehicleDetail {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
seats: number
|
||||
transmission: string
|
||||
fuelType: string
|
||||
features: string[]
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
availability: boolean
|
||||
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt: string | null
|
||||
company: {
|
||||
brand: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
publicPhone: string | null
|
||||
whatsappNumber: string | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
|
||||
const language = await getCarplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: 'Carplace unavailable',
|
||||
unavailableTitle: 'Vehicle details are temporarily unavailable.',
|
||||
unavailableBody: 'The carplace frontend is running, but the backing database is not reachable right now.',
|
||||
backToFleet: 'Back to fleet',
|
||||
backToExplore: 'Back to explore',
|
||||
noPhotos: 'No photos available.',
|
||||
rentalCompany: 'Rental company',
|
||||
perDay: '/ day',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
specs: 'Specifications',
|
||||
year: 'Year',
|
||||
category: 'Category',
|
||||
seats: 'Seats',
|
||||
transmission: 'Transmission',
|
||||
fuel: 'Fuel type',
|
||||
features: 'Features & extras',
|
||||
contact: 'Contact company',
|
||||
phone: 'Phone',
|
||||
whatsapp: 'WhatsApp',
|
||||
photos: 'Photos',
|
||||
},
|
||||
fr: {
|
||||
unavailable: 'Carplace indisponible',
|
||||
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||
unavailableBody: "Le frontend carplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
||||
backToFleet: 'Retour à la flotte',
|
||||
backToExplore: 'Retour à l’exploration',
|
||||
noPhotos: 'Aucune photo disponible.',
|
||||
rentalCompany: 'Société de location',
|
||||
perDay: '/ jour',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
specs: 'Caractéristiques',
|
||||
year: 'Année',
|
||||
category: 'Catégorie',
|
||||
seats: 'Places',
|
||||
transmission: 'Transmission',
|
||||
fuel: 'Carburant',
|
||||
features: 'Équipements & extras',
|
||||
contact: 'Contacter la société',
|
||||
phone: 'Téléphone',
|
||||
whatsapp: 'WhatsApp',
|
||||
photos: 'Photos',
|
||||
},
|
||||
ar: {
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.',
|
||||
backToFleet: 'العودة إلى الأسطول',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
noPhotos: 'لا توجد صور متاحة.',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
perDay: '/ يوم',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
specs: 'المواصفات',
|
||||
year: 'السنة',
|
||||
category: 'الفئة',
|
||||
seats: 'المقاعد',
|
||||
transmission: 'ناقل الحركة',
|
||||
fuel: 'نوع الوقود',
|
||||
features: 'المميزات والإضافات',
|
||||
contact: 'تواصل مع الشركة',
|
||||
phone: 'الهاتف',
|
||||
whatsapp: 'واتساب',
|
||||
photos: 'الصور',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const vehicle = await carplaceFetchOrDefault<VehicleDetail | null>(`/carplace/${params.slug}/vehicles/${params.id}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section">
|
||||
<section className="card p-8">
|
||||
<p className="text-sm uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{dict.unavailable}</p>
|
||||
<h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
|
||||
<p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link href={`/explore/${params.slug}`} className="rounded-full bg-blue-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400">{dict.backToFleet}</Link>
|
||||
<Link href="/explore" className="rounded-full border border-stone-200 px-5 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:text-stone-300 dark:hover:border-stone-500">{dict.backToExplore}</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const availabilityDate = vehicle.nextAvailableAt
|
||||
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: null
|
||||
|
||||
const [heroPhoto, ...galleryPhotos] = vehicle.photos
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-8">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
<Link href="/explore" className="hover:text-stone-900 dark:hover:text-stone-100 transition">{dict.backToExplore}</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/explore/${params.slug}`} className="hover:text-stone-900 dark:hover:text-stone-100 transition">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-900 dark:text-stone-100 font-medium">{vehicle.make} {vehicle.model}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
|
||||
|
||||
{/* Left — photos + specs */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Hero photo */}
|
||||
{heroPhoto ? (
|
||||
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-blue-900/30 aspect-[16/9]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={heroPhoto} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-blue-900/30 aspect-[16/9] flex items-center justify-center text-sm text-stone-400">{dict.noPhotos}</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{galleryPhotos.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">{dict.photos}</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{galleryPhotos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="overflow-hidden rounded-2xl bg-stone-100 dark:bg-blue-900/30 aspect-[4/3]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Specs */}
|
||||
<div className="card p-6 space-y-4">
|
||||
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.specs}</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.year}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.year}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.category}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.category}</dd>
|
||||
</div>
|
||||
{vehicle.seats > 0 && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.seats}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.seats}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.transmission && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.transmission}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.transmission}</dd>
|
||||
</div>
|
||||
)}
|
||||
{vehicle.fuelType && (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.fuel}</dt>
|
||||
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.fuelType}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{vehicle.features.length > 0 && (
|
||||
<div className="pt-2 border-t border-stone-100 dark:border-blue-900">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500 mb-3">{dict.features}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 dark:bg-blue-900/30 px-3 py-1 text-xs font-medium text-stone-700 dark:text-stone-300">{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
{(vehicle.company.brand?.publicPhone || vehicle.company.brand?.whatsappNumber) && (
|
||||
<div className="card p-6 space-y-3">
|
||||
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.contact}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{vehicle.company.brand?.publicPhone && (
|
||||
<a href={`tel:${vehicle.company.brand.publicPhone}`} className="inline-flex items-center gap-2 rounded-full border border-stone-300 dark:border-blue-800 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition">
|
||||
{dict.phone}: {vehicle.company.brand.publicPhone}
|
||||
</a>
|
||||
)}
|
||||
{vehicle.company.brand?.whatsappNumber && (
|
||||
<a href={`https://wa.me/${vehicle.company.brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-full bg-orange-600 px-4 py-2 text-sm font-semibold text-white hover:bg-orange-700 transition">
|
||||
{dict.whatsapp}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — sticky booking panel */}
|
||||
<aside className="space-y-4">
|
||||
<div className="card p-6 space-y-4 lg:sticky lg:top-20">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h1 className="text-3xl font-black text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
|
||||
<p className="text-3xl font-black text-stone-900 dark:text-stone-100">
|
||||
{formatCurrency(vehicle.dailyRate, 'MAD', language)}
|
||||
<span className="text-base font-medium text-stone-500 dark:text-stone-400"> {dict.perDay}</span>
|
||||
</p>
|
||||
|
||||
{!vehicle.availability && (
|
||||
<div className="rounded-2xl border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/40 px-4 py-3 text-sm text-orange-800 dark:text-orange-300">
|
||||
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vehicle.availability && (
|
||||
<BookingForm
|
||||
vehicleId={vehicle.id}
|
||||
companySlug={params.slug}
|
||||
dailyRate={vehicle.dailyRate}
|
||||
pickupLocations={vehicle.pickupLocations}
|
||||
allowDifferentDropoff={vehicle.allowDifferentDropoff}
|
||||
dropoffLocations={vehicle.dropoffLocations}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/explore/${params.slug}`}
|
||||
className="block rounded-full border border-stone-300 dark:border-blue-800 px-6 py-3 text-center text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-blue-900/40 transition"
|
||||
>
|
||||
{dict.backToFleet}
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { carplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import ExploreSearchForm from './ExploreSearchForm'
|
||||
import ExploreVehicleGrid from './ExploreVehicleGrid'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
dailyRate: number
|
||||
photos: string[]
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
availability?: boolean | null
|
||||
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||
nextAvailableAt?: string | null
|
||||
company: {
|
||||
slug: string
|
||||
brand: {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
subdomain: string
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
id: string
|
||||
title: string
|
||||
discountValue: number
|
||||
validUntil: string
|
||||
company: {
|
||||
brand: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
interface CompanyCard {
|
||||
id: string
|
||||
brand: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
publicCity: string | null
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
_count: {
|
||||
vehicles: number
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_VALUES = [
|
||||
'ECONOMY',
|
||||
'COMPACT',
|
||||
'MIDSIZE',
|
||||
'FULLSIZE',
|
||||
'SUV',
|
||||
'LUXURY',
|
||||
'VAN',
|
||||
'TRUCK',
|
||||
] as const
|
||||
|
||||
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const resolvedSearchParams = await searchParams
|
||||
const language = await getCarplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
kicker: 'Discovery only',
|
||||
title: 'Find your next rental from trusted local companies.',
|
||||
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
|
||||
searchTitle: 'Find a vehicle',
|
||||
pickupLocation: 'Pick-up location',
|
||||
dropoffLocation: 'Drop-off location',
|
||||
sameDropoff: 'Same drop-off',
|
||||
differentDropoff: 'Different drop-off',
|
||||
sameAsPickup: 'Return to the same location',
|
||||
pickupDate: 'Start date',
|
||||
returnDate: 'Return date',
|
||||
hour: 'Hour',
|
||||
promoCode: 'I have a promo code',
|
||||
myAge: 'My age',
|
||||
ageDescription: 'Driver age can affect availability, deposit amount, and final pricing.',
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Search',
|
||||
currentDeals: 'Current deals',
|
||||
featuredOffers: 'Featured carplace offers',
|
||||
company: 'Company',
|
||||
validUntil: 'Valid until',
|
||||
offersUnavailable: 'Carplace offers are unavailable right now.',
|
||||
availableVehicles: 'Available vehicles',
|
||||
listings: 'listings',
|
||||
rentalCompany: 'Rental company',
|
||||
checkAvailability: 'Check availability',
|
||||
available: 'Available',
|
||||
reserved: 'Reserved',
|
||||
maintenance: 'In maintenance',
|
||||
unavailableStatus: 'Unavailable',
|
||||
from: 'From',
|
||||
viewVehicle: 'View vehicle',
|
||||
availableFrom: 'Available from',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||
browseByCategory: 'Browse by category',
|
||||
browseByCompany: 'Browse by company',
|
||||
carplacePartners: 'Carplace partners',
|
||||
carplacePartner: 'Carplace partner',
|
||||
rating: 'Rating',
|
||||
new: 'New',
|
||||
publishedVehicles: 'published vehicles',
|
||||
viewFleet: 'View fleet',
|
||||
categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Découverte uniquement',
|
||||
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
|
||||
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
|
||||
searchTitle: 'Trouver un véhicule',
|
||||
pickupLocation: 'Lieu de départ',
|
||||
dropoffLocation: "Lieu de retour",
|
||||
sameDropoff: 'Même retour',
|
||||
differentDropoff: 'Retour différent',
|
||||
sameAsPickup: 'Retour au même lieu',
|
||||
pickupDate: 'Date de départ',
|
||||
returnDate: 'Date de retour',
|
||||
hour: 'Heure',
|
||||
promoCode: "J'ai un code promo",
|
||||
myAge: 'Mon âge',
|
||||
ageDescription: "L'âge du conducteur peut influencer la disponibilité, le dépôt de garantie et le prix final.",
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'Rechercher',
|
||||
currentDeals: 'Offres du moment',
|
||||
featuredOffers: 'Offres carplace mises en avant',
|
||||
company: 'Entreprise',
|
||||
validUntil: "Valable jusqu'au",
|
||||
offersUnavailable: 'Les offres carplace sont indisponibles pour le moment.',
|
||||
availableVehicles: 'Véhicules disponibles',
|
||||
listings: 'annonces',
|
||||
rentalCompany: 'Société de location',
|
||||
checkAvailability: 'Vérifier la disponibilité',
|
||||
available: 'Disponible',
|
||||
reserved: 'Réservé',
|
||||
maintenance: 'En maintenance',
|
||||
unavailableStatus: 'Indisponible',
|
||||
from: 'À partir de',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
availableFrom: 'Disponible à partir du',
|
||||
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
||||
browseByCategory: 'Parcourir par catégorie',
|
||||
browseByCompany: 'Parcourir par entreprise',
|
||||
carplacePartners: 'Partenaires carplace',
|
||||
carplacePartner: 'Partenaire carplace',
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
publishedVehicles: 'véhicules publiés',
|
||||
viewFleet: 'Voir la flotte',
|
||||
categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'للاستكشاف فقط',
|
||||
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
||||
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
|
||||
searchTitle: 'احجز سيارة',
|
||||
pickupLocation: 'موقع الاستلام',
|
||||
dropoffLocation: 'موقع الإرجاع',
|
||||
sameDropoff: 'نفس موقع الإرجاع',
|
||||
differentDropoff: 'موقع إرجاع مختلف',
|
||||
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
|
||||
pickupDate: 'تاريخ البدء',
|
||||
returnDate: 'تاريخ الإرجاع',
|
||||
hour: 'الساعة',
|
||||
promoCode: 'لدي رمز ترويجي',
|
||||
myAge: 'عمري',
|
||||
ageDescription: 'قد يؤثر عمر السائق على التوفر ومبلغ التأمين والسعر النهائي.',
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'بحث',
|
||||
currentDeals: 'العروض الحالية',
|
||||
featuredOffers: 'عروض السوق المميزة',
|
||||
company: 'شركة',
|
||||
validUntil: 'صالح حتى',
|
||||
offersUnavailable: 'عروض السوق غير متاحة حالياً.',
|
||||
availableVehicles: 'السيارات المتاحة',
|
||||
listings: 'إعلانات',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
checkAvailability: 'تحقق من التوفر',
|
||||
available: 'متاح',
|
||||
reserved: 'محجوز',
|
||||
maintenance: 'قيد الصيانة',
|
||||
unavailableStatus: 'غير متاح',
|
||||
from: 'ابتداءً من',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||
browseByCategory: 'تصفح حسب الفئة',
|
||||
browseByCompany: 'تصفح حسب الشركة',
|
||||
carplacePartners: 'شركاء السوق',
|
||||
carplacePartner: 'شريك في السوق',
|
||||
rating: 'التقييم',
|
||||
new: 'جديد',
|
||||
publishedVehicles: 'سيارات منشورة',
|
||||
viewFleet: 'عرض الأسطول',
|
||||
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
|
||||
},
|
||||
}[language]
|
||||
|
||||
const city = typeof resolvedSearchParams?.city === 'string' ? resolvedSearchParams.city : ''
|
||||
const pickupLocation = typeof resolvedSearchParams?.pickupLocation === 'string' ? resolvedSearchParams.pickupLocation : city
|
||||
const dropoffLocation = typeof resolvedSearchParams?.dropoffLocation === 'string' ? resolvedSearchParams.dropoffLocation : ''
|
||||
const dropoffMode = resolvedSearchParams?.dropoffMode === 'different' ? 'different' : 'same'
|
||||
const pickupDate = typeof resolvedSearchParams?.pickupDate === 'string' ? resolvedSearchParams.pickupDate : ''
|
||||
const pickupTime = typeof resolvedSearchParams?.pickupTime === 'string' ? resolvedSearchParams.pickupTime : '10:00'
|
||||
const returnDate = typeof resolvedSearchParams?.returnDate === 'string' ? resolvedSearchParams.returnDate : ''
|
||||
const returnTime = typeof resolvedSearchParams?.returnTime === 'string' ? resolvedSearchParams.returnTime : '10:00'
|
||||
const promoCode = typeof resolvedSearchParams?.promoCode === 'string' ? resolvedSearchParams.promoCode : ''
|
||||
const driverAge = typeof resolvedSearchParams?.driverAge === 'string' ? resolvedSearchParams.driverAge : '23+'
|
||||
const category = typeof resolvedSearchParams?.category === 'string' ? resolvedSearchParams.category : ''
|
||||
const transmission = typeof resolvedSearchParams?.transmission === 'string' ? resolvedSearchParams.transmission : ''
|
||||
const make = typeof resolvedSearchParams?.make === 'string' ? resolvedSearchParams.make : ''
|
||||
const model = typeof resolvedSearchParams?.model === 'string' ? resolvedSearchParams.model : ''
|
||||
const startDate = typeof resolvedSearchParams?.startDate === 'string' ? resolvedSearchParams.startDate : ''
|
||||
const endDate = typeof resolvedSearchParams?.endDate === 'string' ? resolvedSearchParams.endDate : ''
|
||||
|
||||
const query = new URLSearchParams()
|
||||
if (pickupLocation) query.set('city', pickupLocation)
|
||||
if (pickupLocation) query.set('pickupLocation', pickupLocation)
|
||||
query.set('dropoffMode', dropoffMode)
|
||||
if (dropoffMode === 'different' && dropoffLocation) query.set('dropoffLocation', dropoffLocation)
|
||||
if (startDate) query.set('startDate', startDate)
|
||||
if (endDate) query.set('endDate', endDate)
|
||||
if (category) query.set('category', category)
|
||||
if (transmission) query.set('transmission', transmission)
|
||||
if (make) query.set('make', make)
|
||||
if (model) query.set('model', model)
|
||||
query.set('pageSize', '24')
|
||||
|
||||
const [offers, vehicles, companies] = await Promise.all([
|
||||
carplaceFetchOrDefault<Offer[]>('/carplace/offers', []),
|
||||
carplaceFetchOrDefault<Vehicle[]>(`/carplace/search?${query.toString()}`, []),
|
||||
carplaceFetchOrDefault<CompanyCard[]>('/carplace/companies?pageSize=8', []),
|
||||
])
|
||||
const cities = Array.from(
|
||||
new Set(
|
||||
companies
|
||||
.map((company) => company.brand?.publicCity?.trim())
|
||||
.filter((city): city is string => Boolean(city))
|
||||
)
|
||||
).sort((left, right) => left.localeCompare(right))
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-10">
|
||||
<section className="site-panel-contrast bg-[linear-gradient(135deg,#0f2460,#1a3a8f)] dark:bg-[linear-gradient(135deg,#0a1735,#112d6e)] px-8 py-12">
|
||||
<p className="site-kicker text-orange-300 dark:text-orange-300">{dict.kicker}</p>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
|
||||
<p className="mt-4 max-w-2xl text-blue-100 dark:text-blue-100">{dict.body}</p>
|
||||
<ExploreSearchForm
|
||||
pickupLocation={pickupLocation}
|
||||
dropoffLocation={dropoffLocation}
|
||||
dropoffMode={dropoffMode}
|
||||
pickupDate={pickupDate}
|
||||
pickupTime={pickupTime}
|
||||
returnDate={returnDate}
|
||||
returnTime={returnTime}
|
||||
promoCode={promoCode}
|
||||
driverAge={driverAge}
|
||||
cities={cities}
|
||||
dict={dict}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.currentDeals}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.featuredOffers}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{offers.map((offer) => (
|
||||
<Link key={offer.id} href={`/explore/${offer.company.brand?.subdomain ?? ''}#offers`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
|
||||
<p className="truncate text-xs uppercase tracking-[0.18em] text-orange-700 dark:text-orange-400">{offer.company.brand?.displayName ?? dict.company}</p>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{offer.title}</h3>
|
||||
<p className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{offer.discountValue}%</p>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.validUntil} {new Date(offer.validUntil).toLocaleDateString()}</p>
|
||||
</Link>
|
||||
))}
|
||||
{offers.length === 0 && <div className="card p-6 text-sm text-stone-500 dark:text-stone-400 md:col-span-2 xl:col-span-4">{dict.offersUnavailable}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ExploreVehicleGrid vehicles={vehicles} dict={dict} language={language} />
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCategory}</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{dict.categories.map((categoryName, index) => (
|
||||
<Link key={CATEGORY_VALUES[index] ?? categoryName} href={`/explore?category=${CATEGORY_VALUES[index]}`} className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-stone-400 dark:border-blue-800 dark:bg-blue-950 dark:text-stone-300 dark:hover:border-stone-500">
|
||||
{categoryName}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.browseByCompany}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.carplacePartners}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{companies.map((company) => (
|
||||
<Link key={company.id} href={`/explore/${company.brand?.subdomain ?? ''}`} className="card flex flex-col p-5 transition-colors hover:border-orange-300 dark:hover:border-orange-500">
|
||||
<p className="truncate text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{company.brand?.publicCity ?? dict.carplacePartner}</p>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-stone-900 dark:text-stone-100">{company.brand?.displayName ?? dict.rentalCompany}</h3>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.rating} {company.brand?.marketplaceRating?.toFixed(1) ?? dict.new}</p>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{company._count.vehicles} {dict.publishedVehicles}</p>
|
||||
<span className="mt-4 inline-flex self-start rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white dark:bg-orange-500 dark:text-white">{dict.viewFleet}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
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} />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import SitePageLayout from '@/components/public/SitePageLayout'
|
||||
|
||||
export default function PublicLayout({ children }: { children: ReactNode }) {
|
||||
return <SitePageLayout>{children}</SitePageLayout>
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CarplaceHomePage() {
|
||||
redirect('/explore')
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
@import "../styles/phase16-tokens.css";
|
||||
|
||||
@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, #0a1535 0%, #0d1f52 35%, #091228 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 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 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(96, 165, 250, 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 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 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, #f5f8ff 0%, #edf2ff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-image: linear-gradient(160deg, #0c1830 0%, #10203e 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(14 40 90 / 0.8);
|
||||
background-color: rgb(10 25 75);
|
||||
}
|
||||
|
||||
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-blue-800 dark:bg-blue-950/30 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
|
||||
}
|
||||
|
||||
/* Phase 16 operational application layer */
|
||||
@layer base {
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--rdg-surface-page);
|
||||
color: var(--rdg-text-primary);
|
||||
scroll-behavior: smooth;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--rdg-surface-page);
|
||||
color: var(--rdg-text-primary);
|
||||
font-family: var(--rdg-font-latin);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--rdg-text-primary);
|
||||
line-height: 1.18;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.shell {
|
||||
width: min(100%, var(--rdg-container-max));
|
||||
}
|
||||
|
||||
.panel,
|
||||
.card,
|
||||
.glass-card,
|
||||
.site-panel {
|
||||
border-color: var(--rdg-border);
|
||||
border-radius: var(--rdg-radius-lg);
|
||||
background: color-mix(in srgb, var(--rdg-surface-elevated) 92%, transparent);
|
||||
box-shadow: var(--rdg-shadow-card);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
border-color: var(--rdg-border);
|
||||
border-radius: var(--rdg-radius-lg);
|
||||
background: var(--rdg-surface-muted);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
border-color: color-mix(in srgb, var(--rdg-blue-700) 68%, var(--rdg-border));
|
||||
border-radius: var(--rdg-radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at 90% 10%, color-mix(in srgb, var(--rdg-blue-500) 30%, transparent), transparent 40%),
|
||||
linear-gradient(135deg, var(--rdg-navy-900), var(--rdg-blue-800));
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.site-link-primary {
|
||||
min-height: var(--rdg-touch-min);
|
||||
border-radius: var(--rdg-radius-sm);
|
||||
background: var(--rdg-action-conversion);
|
||||
color: #ffffff;
|
||||
box-shadow: var(--rdg-shadow-subtle);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.site-link-primary:hover {
|
||||
background: var(--rdg-action-conversion-hover);
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.site-link-secondary {
|
||||
min-height: var(--rdg-touch-min);
|
||||
border-color: var(--rdg-border-strong);
|
||||
border-radius: var(--rdg-radius-sm);
|
||||
background: var(--rdg-surface-primary);
|
||||
color: var(--rdg-action-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.site-link-secondary:hover {
|
||||
border-color: var(--rdg-action-primary);
|
||||
background: var(--rdg-soft-blue);
|
||||
color: var(--rdg-action-primary-hover);
|
||||
}
|
||||
|
||||
.input-field,
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='range']),
|
||||
select,
|
||||
textarea {
|
||||
min-height: var(--rdg-touch-min);
|
||||
border-color: var(--rdg-border);
|
||||
border-radius: var(--rdg-radius-sm);
|
||||
background: var(--rdg-surface-primary);
|
||||
color: var(--rdg-text-primary);
|
||||
}
|
||||
|
||||
.input-field::placeholder,
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--rdg-text-subdued);
|
||||
}
|
||||
|
||||
table {
|
||||
border-color: var(--rdg-border);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--rdg-surface-muted);
|
||||
color: var(--rdg-text-secondary);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-color: var(--rdg-border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.panel,
|
||||
.card,
|
||||
.glass-card,
|
||||
.site-panel,
|
||||
.site-panel-muted,
|
||||
.site-panel-contrast {
|
||||
border: 1px solid CanvasText;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark body,
|
||||
html[data-theme='dark'] body,
|
||||
.site-page,
|
||||
html.dark .site-page,
|
||||
html[data-theme='dark'] .site-page {
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--rdg-soft-blue) 78%, transparent), transparent 30%),
|
||||
var(--rdg-surface-page);
|
||||
color: var(--rdg-text-primary);
|
||||
}
|
||||
|
||||
.site-glow,
|
||||
html.dark .site-glow,
|
||||
html[data-theme='dark'] .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--rdg-soft-orange) 72%, transparent), transparent 34%),
|
||||
radial-gradient(circle at top right, color-mix(in srgb, var(--rdg-soft-blue) 72%, transparent), transparent 28%);
|
||||
}
|
||||
|
||||
html.dark .card,
|
||||
html.dark .site-panel,
|
||||
html.dark .site-panel-muted,
|
||||
html[data-theme='dark'] .card,
|
||||
html[data-theme='dark'] .site-panel,
|
||||
html[data-theme='dark'] .site-panel-muted {
|
||||
border-color: var(--rdg-border);
|
||||
background: color-mix(in srgb, var(--rdg-surface-elevated) 92%, transparent);
|
||||
box-shadow: var(--rdg-shadow-card);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import CarplaceShell from '@/components/CarplaceShell'
|
||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Carplace',
|
||||
description: 'Discover vehicles from trusted rental companies.',
|
||||
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 ??
|
||||
cookieStore.get('carplace-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')||localStorage.getItem('carplace-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<CarplaceShell initialLanguage={language} initialTheme={theme}>{children}</CarplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface SavedCompany {
|
||||
id: string
|
||||
brand?: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface RenterProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
savedCompanies?: SavedCompany[]
|
||||
}
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
|
||||
export default function RenterDashboardPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
loadProfile: 'Could not load profile.',
|
||||
reachServer: 'Could not reach the server.',
|
||||
backToExplore: 'Back to explore',
|
||||
renterDashboard: 'Renter Dashboard',
|
||||
welcome: 'Welcome back,',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
logout: 'Log out',
|
||||
cards: [
|
||||
['Profile', 'Review your account details and preferences.'],
|
||||
['Saved companies', 'Jump back into companies you bookmarked.'],
|
||||
['Notifications', 'See booking updates and in-app alerts.'],
|
||||
],
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
savedCompanies: 'Saved companies',
|
||||
noneSaved: 'None saved yet',
|
||||
saved: 'saved',
|
||||
saveCompaniesPrompt: 'Save companies you like while browsing the carplace.',
|
||||
startExploring: 'Start exploring',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
loadProfile: 'Impossible de charger le profil.',
|
||||
reachServer: 'Impossible de joindre le serveur.',
|
||||
backToExplore: 'Retour à l’exploration',
|
||||
renterDashboard: 'Espace client',
|
||||
welcome: 'Bon retour,',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
logout: 'Déconnexion',
|
||||
cards: [
|
||||
['Profil', 'Consultez vos informations et préférences.'],
|
||||
['Entreprises sauvegardées', 'Revenez vers les entreprises que vous avez enregistrées.'],
|
||||
['Notifications', 'Consultez les mises à jour de réservation et les alertes.'],
|
||||
],
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'E-mail',
|
||||
phone: 'Téléphone',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
noneSaved: 'Aucune pour le moment',
|
||||
saved: 'sauvegardées',
|
||||
saveCompaniesPrompt: 'Enregistrez les entreprises que vous aimez en parcourant la carplace.',
|
||||
startExploring: 'Commencer à explorer',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
loadProfile: 'تعذر تحميل الملف الشخصي.',
|
||||
reachServer: 'تعذر الوصول إلى الخادم.',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
renterDashboard: 'بوابة المستأجر',
|
||||
welcome: 'مرحباً بعودتك،',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
cards: [
|
||||
['الملف الشخصي', 'راجع بيانات حسابك وتفضيلاتك.'],
|
||||
['الشركات المحفوظة', 'ارجع بسرعة إلى الشركات التي حفظتها.'],
|
||||
['الإشعارات', 'اطلع على تحديثات الحجز والتنبيهات داخل التطبيق.'],
|
||||
],
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
noneSaved: 'لا يوجد شيء محفوظ بعد',
|
||||
saved: 'محفوظة',
|
||||
saveCompaniesPrompt: 'احفظ الشركات التي تعجبك أثناء تصفح السوق.',
|
||||
startExploring: 'ابدأ الاستكشاف',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/auth/renter/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (res) => {
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
|
||||
router.replace('/')
|
||||
} else {
|
||||
setErrorMsg(json?.message ?? dict.loadProfile)
|
||||
setStatus('error')
|
||||
}
|
||||
return
|
||||
}
|
||||
setProfile(json?.data ?? json)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMsg(dict.reachServer)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 w-48 rounded-xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
<div className="h-40 rounded-2xl bg-stone-200" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="shell">
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{errorMsg}</p>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="mt-4 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white"
|
||||
>
|
||||
{dict.backToExplore}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const savedCompanies = profile?.savedCompanies ?? []
|
||||
|
||||
return (
|
||||
<div className="shell space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700">
|
||||
{dict.renterDashboard}
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-black tracking-tight text-blue-900">
|
||||
{dict.welcome} {profile?.firstName}
|
||||
</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="self-start rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300"
|
||||
>
|
||||
{dict.exploreVehicles}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ href: '/renter/profile', label: dict.cards[0][0], copy: dict.cards[0][1] },
|
||||
{ href: '/renter/saved-companies', label: dict.cards[1][0], copy: dict.cards[1][1] },
|
||||
{ href: '/renter/notifications', label: dict.cards[2][0], copy: dict.cards[2][1] },
|
||||
].map((item) => (
|
||||
<Link key={item.href} href={item.href} className="card p-5 hover:border-orange-300 transition-colors">
|
||||
<p className="text-sm font-semibold text-blue-900">{item.label}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-500">{item.copy}</p>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Profile card */}
|
||||
<section className="card p-8">
|
||||
<h2 className="text-lg font-bold text-blue-900">{dict.yourProfile}</h2>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: dict.firstName, value: profile?.firstName },
|
||||
{ label: dict.lastName, value: profile?.lastName },
|
||||
{ label: dict.email, value: profile?.email },
|
||||
{ label: dict.phone, value: profile?.phone ?? '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm font-medium text-blue-900 break-all">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Saved companies */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-blue-900">{dict.savedCompanies}</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
{savedCompanies.length === 0
|
||||
? dict.noneSaved
|
||||
: `${savedCompanies.length} ${dict.saved}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{savedCompanies.length === 0 ? (
|
||||
<div className="card mt-4 flex flex-col items-center gap-4 py-16 text-center">
|
||||
<svg
|
||||
className="h-10 w-10 text-stone-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-500">
|
||||
{dict.saveCompaniesPrompt}
|
||||
</p>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-orange-700"
|
||||
>
|
||||
{dict.startExploring}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{savedCompanies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={company.brand.logoUrl}
|
||||
alt={company.brand.displayName}
|
||||
className="h-10 w-10 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 text-orange-700 font-bold text-sm">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-blue-900">
|
||||
{company.brand?.displayName ?? dict.rentalCompany}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-stone-400">
|
||||
{company.brand?.subdomain ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
className="ml-auto h-4 w-4 shrink-0 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import RenterShell from '@/components/RenterShell'
|
||||
|
||||
export default function RenterLayout({ children }: { children: React.ReactNode }) {
|
||||
return <RenterShell>{children}</RenterShell>
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
loadRenterNotifications,
|
||||
markAllRenterNotificationsRead,
|
||||
markRenterNotificationRead,
|
||||
type RenterNotification,
|
||||
} from '@/lib/renter'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
export default function RenterNotificationsPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load notifications.',
|
||||
updateOne: 'Could not update the notification.',
|
||||
updateAll: 'Could not update notifications.',
|
||||
renter: 'Renter',
|
||||
title: 'Notifications',
|
||||
markAll: 'Mark all read',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading notifications…',
|
||||
empty: 'No renter notifications yet.',
|
||||
read: 'Read',
|
||||
markRead: 'Mark read',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les notifications.',
|
||||
updateOne: 'Impossible de mettre à jour la notification.',
|
||||
updateAll: 'Impossible de mettre à jour les notifications.',
|
||||
renter: 'Client',
|
||||
title: 'Notifications',
|
||||
markAll: 'Tout marquer comme lu',
|
||||
back: 'Retour au tableau de bord',
|
||||
loading: 'Chargement des notifications…',
|
||||
empty: 'Aucune notification client pour le moment.',
|
||||
read: 'Lu',
|
||||
markRead: 'Marquer comme lu',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الإشعارات.',
|
||||
updateOne: 'تعذر تحديث الإشعار.',
|
||||
updateAll: 'تعذر تحديث الإشعارات.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الإشعارات',
|
||||
markAll: 'تعيين الكل كمقروء',
|
||||
back: 'العودة إلى بوابة المستأجر',
|
||||
loading: 'جارٍ تحميل الإشعارات…',
|
||||
empty: 'لا توجد إشعارات للمستأجر حالياً.',
|
||||
read: 'مقروء',
|
||||
markRead: 'تعيين كمقروء',
|
||||
},
|
||||
}[language]
|
||||
const [notifications, setNotifications] = useState<RenterNotification[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterNotifications()
|
||||
.then((items) => setNotifications(items))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
async function markRead(id: string) {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markRenterNotificationRead(id)
|
||||
setNotifications((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item)),
|
||||
)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateOne)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await markAllRenterNotificationsRead()
|
||||
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.updateAll)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.title}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllRead}
|
||||
disabled={loading || updating}
|
||||
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700"
|
||||
>
|
||||
{dict.markAll}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length === 0 ? (
|
||||
<div className="card p-10 text-center text-sm text-stone-500">{dict.empty}</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && notifications.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-orange-700">{notification.type.replaceAll('_', ' ')}</p>
|
||||
<h2 className="mt-2 text-lg font-bold text-blue-900">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">{dict.read}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markRead(notification.id)}
|
||||
disabled={updating}
|
||||
className="rounded-full bg-orange-100 px-3 py-1 text-xs font-semibold text-orange-800"
|
||||
>
|
||||
{dict.markRead}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{notification.body}</p>
|
||||
<p className="mt-4 text-xs text-stone-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
RenterAuthError,
|
||||
clearRenterSession,
|
||||
loadRenterPreferences,
|
||||
loadRenterProfile,
|
||||
updateRenterPreferences,
|
||||
updateRenterProfile,
|
||||
type RenterPreference,
|
||||
type RenterProfile,
|
||||
} from '@/lib/renter'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'error'
|
||||
const RENTER_EVENTS = ['BOOKING_CONFIRMED', 'BOOKING_REMINDER', 'RETURN_REMINDER', 'REFUND_ISSUED', 'NEW_OFFER']
|
||||
const RENTER_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'PUSH', 'IN_APP']
|
||||
|
||||
export default function RenterProfilePage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
profile: 'Profile',
|
||||
loading: 'Loading profile…',
|
||||
loadFailed: 'Could not load profile.',
|
||||
yourProfile: 'Your profile',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
locale: 'Locale',
|
||||
currency: 'Currency',
|
||||
saving: 'Saving…',
|
||||
saveProfile: 'Save profile',
|
||||
verification: 'Verification',
|
||||
verified: 'Email verified',
|
||||
pending: 'Email verification is still pending.',
|
||||
notificationPrefs: 'Notification preferences',
|
||||
notificationBody: 'Choose how reminders, offers, and booking updates reach you.',
|
||||
savePreferences: 'Save preferences',
|
||||
event: 'Event',
|
||||
back: 'Back to dashboard',
|
||||
savedCompanies: 'Saved companies',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Log out',
|
||||
renter: 'Renter',
|
||||
saveFailed: 'Could not save profile.',
|
||||
prefFailed: 'Could not save preferences.',
|
||||
},
|
||||
fr: {
|
||||
profile: 'Profil',
|
||||
loading: 'Chargement du profil…',
|
||||
loadFailed: 'Impossible de charger le profil.',
|
||||
yourProfile: 'Votre profil',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
locale: 'Langue',
|
||||
currency: 'Devise',
|
||||
saving: 'Enregistrement…',
|
||||
saveProfile: 'Enregistrer le profil',
|
||||
verification: 'Vérification',
|
||||
verified: 'Email vérifié',
|
||||
pending: 'La vérification de l’email est encore en attente.',
|
||||
notificationPrefs: 'Préférences de notification',
|
||||
notificationBody: 'Choisissez comment les rappels, offres et mises à jour vous parviennent.',
|
||||
savePreferences: 'Enregistrer les préférences',
|
||||
event: 'Événement',
|
||||
back: 'Retour au dashboard',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
notifications: 'Notifications',
|
||||
logout: 'Déconnexion',
|
||||
renter: 'Client',
|
||||
saveFailed: 'Impossible d’enregistrer le profil.',
|
||||
prefFailed: 'Impossible d’enregistrer les préférences.',
|
||||
},
|
||||
ar: {
|
||||
profile: 'الملف الشخصي',
|
||||
loading: 'جارٍ تحميل الملف الشخصي…',
|
||||
loadFailed: 'تعذر تحميل الملف الشخصي.',
|
||||
yourProfile: 'ملفك الشخصي',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
locale: 'اللغة',
|
||||
currency: 'العملة',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
saveProfile: 'حفظ الملف الشخصي',
|
||||
verification: 'التحقق',
|
||||
verified: 'تم التحقق من البريد الإلكتروني',
|
||||
pending: 'التحقق من البريد الإلكتروني ما زال قيد الانتظار.',
|
||||
notificationPrefs: 'تفضيلات الإشعارات',
|
||||
notificationBody: 'اختر كيف تصلك التذكيرات والعروض وتحديثات الحجز.',
|
||||
savePreferences: 'حفظ التفضيلات',
|
||||
event: 'الحدث',
|
||||
back: 'العودة إلى اللوحة',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
notifications: 'الإشعارات',
|
||||
logout: 'تسجيل الخروج',
|
||||
renter: 'المستأجر',
|
||||
saveFailed: 'تعذر حفظ الملف الشخصي.',
|
||||
prefFailed: 'تعذر حفظ التفضيلات.',
|
||||
},
|
||||
}[language]
|
||||
const [profile, setProfile] = useState<RenterProfile | null>(null)
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [status, setStatus] = useState<Status>('loading')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadRenterProfile(), loadRenterPreferences()])
|
||||
.then(([profileData, preferenceData]) => {
|
||||
setProfile(profileData)
|
||||
setPreferences(
|
||||
Object.fromEntries(
|
||||
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
|
||||
),
|
||||
)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.loadFailed)
|
||||
setStatus('error')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div className="shell"><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></div>
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <div className="shell"><ErrorCard message={errorMsg} /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6">
|
||||
<div className="card p-8">
|
||||
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.yourProfile}</h1>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<EditableField label={dict.firstName} value={profile?.firstName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, firstName: value } : current)} />
|
||||
<EditableField label={dict.lastName} value={profile?.lastName ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, lastName: value } : current)} />
|
||||
<Field label={dict.email} value={profile?.email} />
|
||||
<EditableField label={dict.phone} value={profile?.phone ?? ''} onChange={(value) => setProfile((current) => current ? { ...current, phone: value } : current)} />
|
||||
<SelectField
|
||||
label={dict.locale}
|
||||
value={profile?.preferredLocale || 'en'}
|
||||
options={['en', 'fr', 'ar']}
|
||||
onChange={(value) => setProfile((current) => current ? { ...current, preferredLocale: value } : current)}
|
||||
/>
|
||||
<SelectField
|
||||
label={dict.currency}
|
||||
value="MAD"
|
||||
options={['MAD']}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!profile) return
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const updated = await updateRenterProfile({
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
phone: profile.phone,
|
||||
preferredLocale: profile.preferredLocale,
|
||||
preferredCurrency: profile.preferredCurrency,
|
||||
})
|
||||
setProfile(updated)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.saveProfile}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8 rounded-2xl border border-stone-200 bg-stone-50 p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">{dict.verification}</p>
|
||||
<p className="mt-2 text-sm text-stone-700">
|
||||
{profile?.emailVerified ? dict.verified : dict.pending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-blue-900">{dict.notificationPrefs}</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">{dict.notificationBody}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSaving(true)
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const payload: RenterPreference[] = Object.entries(preferences).map(([key, enabled]) => {
|
||||
const [notificationType, channel] = key.split(':')
|
||||
return { notificationType, channel, enabled }
|
||||
})
|
||||
await updateRenterPreferences(payload)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.prefFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
{saving ? dict.saving : dict.savePreferences}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-stone-500">{dict.event}</th>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-stone-500">
|
||||
{channel}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{RENTER_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-stone-100">
|
||||
<td className="px-4 py-3 font-medium text-blue-900">{eventName.replaceAll('_', ' ')}</td>
|
||||
{RENTER_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences[`${eventName}:${channel}`] ?? true}
|
||||
onChange={(event) =>
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
[`${eventName}:${channel}`]: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="h-4 w-4 rounded border-stone-300 text-orange-600"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<p className="mt-2 text-sm font-medium text-blue-900 break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditableField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-blue-900"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-400">{label}</p>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-blue-900"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorCard({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="card p-8 text-center">
|
||||
<p className="text-sm text-rose-600">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { RenterAuthError, loadRenterProfile, type SavedCompany } from '@/lib/renter'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
export default function RenterSavedCompaniesPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = {
|
||||
en: {
|
||||
load: 'Could not load saved companies.',
|
||||
renter: 'Renter',
|
||||
title: 'Saved companies',
|
||||
back: 'Back to dashboard',
|
||||
loading: 'Loading saved companies…',
|
||||
empty: 'You have not saved any companies yet.',
|
||||
browse: 'Browse the carplace',
|
||||
rentalCompany: 'Rental company',
|
||||
},
|
||||
fr: {
|
||||
load: 'Impossible de charger les entreprises sauvegardées.',
|
||||
renter: 'Client',
|
||||
title: 'Entreprises sauvegardées',
|
||||
back: 'Retour au tableau de bord',
|
||||
loading: 'Chargement des entreprises sauvegardées…',
|
||||
empty: 'Vous n’avez encore enregistré aucune entreprise.',
|
||||
browse: 'Parcourir la carplace',
|
||||
rentalCompany: 'Société de location',
|
||||
},
|
||||
ar: {
|
||||
load: 'تعذر تحميل الشركات المحفوظة.',
|
||||
renter: 'المستأجر',
|
||||
title: 'الشركات المحفوظة',
|
||||
back: 'العودة إلى بوابة المستأجر',
|
||||
loading: 'جارٍ تحميل الشركات المحفوظة…',
|
||||
empty: 'لم تقم بحفظ أي شركة بعد.',
|
||||
browse: 'تصفح السوق',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
},
|
||||
}[language]
|
||||
const [companies, setCompanies] = useState<SavedCompany[]>([])
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterProfile()
|
||||
.then((profile) => setCompanies(profile.savedCompanies ?? []))
|
||||
.catch((err) => {
|
||||
if (err instanceof RenterAuthError) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
setErrorMsg(err instanceof Error ? err.message : dict.load)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="shell space-y-6">
|
||||
<h1 className="text-3xl font-black tracking-tight text-blue-900">{dict.title}</h1>
|
||||
|
||||
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
|
||||
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length === 0 ? (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-sm text-stone-500">{dict.empty}</p>
|
||||
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-orange-600 px-6 py-2.5 text-sm font-semibold text-white">
|
||||
{dict.browse}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !errorMsg && companies.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{companies.map((company) => (
|
||||
<Link
|
||||
key={company.id}
|
||||
href={`/explore/${company.brand?.subdomain ?? company.id}`}
|
||||
className="card flex items-center gap-4 p-5 hover:border-orange-300 transition-colors"
|
||||
>
|
||||
{company.brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={company.brand.logoUrl} alt={company.brand.displayName} className="h-11 w-11 rounded-xl object-contain" />
|
||||
) : (
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-orange-100 text-sm font-bold text-orange-700">
|
||||
{company.brand?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-blue-900">{company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<p className="mt-1 truncate text-xs text-stone-400">{company.brand?.subdomain ?? company.id}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
function buildSearch(params: SearchParams): string {
|
||||
const nextParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') {
|
||||
nextParams.set(key, value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) nextParams.append(key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const search = nextParams.toString()
|
||||
return search ? `?${search}` : ''
|
||||
}
|
||||
|
||||
export default async function RenterSignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
const params = await searchParams
|
||||
|
||||
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
function buildSearch(params: SearchParams): string {
|
||||
const nextParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') {
|
||||
nextParams.set(key, value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) nextParams.append(key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const search = nextParams.toString()
|
||||
return search ? `?${search}` : ''
|
||||
}
|
||||
|
||||
export default async function RenterSignUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
const params = await searchParams
|
||||
|
||||
redirect(`${dashboardUrl}/sign-up${buildSearch(params)}`)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
function buildSearch(params: SearchParams): string {
|
||||
|
||||
const nextParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') {
|
||||
nextParams.set(key, value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) nextParams.append(key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
const search = nextParams.toString()
|
||||
return search ? `?${search}` : ''
|
||||
}
|
||||
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
const params = await searchParams
|
||||
|
||||
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateBookingTotalDays, normalizeOptionalText, resolveReturnLocation } from './BookingForm'
|
||||
|
||||
describe('BookingForm helpers', () => {
|
||||
it('calculates booking nights from date-only inputs', () => {
|
||||
expect(calculateBookingTotalDays('2026-06-09', '2026-06-10')).toBe(1)
|
||||
expect(calculateBookingTotalDays('2026-06-09', '2026-06-16')).toBe(7)
|
||||
})
|
||||
|
||||
it('never returns a negative total for reversed or incomplete dates', () => {
|
||||
expect(calculateBookingTotalDays('2026-06-16', '2026-06-09')).toBe(0)
|
||||
expect(calculateBookingTotalDays('', '2026-06-09')).toBe(0)
|
||||
expect(calculateBookingTotalDays('2026-06-09', '')).toBe(0)
|
||||
})
|
||||
|
||||
it('uses pickup location for same-location returns', () => {
|
||||
expect(resolveReturnLocation({ dropoffMode: 'same', pickupLocation: 'Marrakesh Airport', returnLocation: 'Casablanca' })).toBe('Marrakesh Airport')
|
||||
})
|
||||
|
||||
it('uses the explicit return location for different dropoffs and preserves an unset optional value', () => {
|
||||
expect(resolveReturnLocation({ dropoffMode: 'different', pickupLocation: 'Marrakesh', returnLocation: 'Casablanca' })).toBe('Casablanca')
|
||||
expect(resolveReturnLocation({ dropoffMode: 'different', pickupLocation: 'Marrakesh', returnLocation: '' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes optional free-text fields without leaking empty strings into payloads', () => {
|
||||
expect(normalizeOptionalText(' flight lands at 9 ')).toBe('flight lands at 9')
|
||||
expect(normalizeOptionalText(' ')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,721 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { CarplaceApiError, carplaceFetch, carplacePost } from '@/lib/api'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
|
||||
type Props = {
|
||||
vehicleId: string
|
||||
companySlug: string
|
||||
dailyRate: number
|
||||
pickupLocations: string[]
|
||||
allowDifferentDropoff: boolean
|
||||
dropoffLocations: string[]
|
||||
}
|
||||
|
||||
type DropoffMode = 'same' | 'different'
|
||||
|
||||
type BookingFields = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
pickupLocation: string
|
||||
dropoffMode: DropoffMode
|
||||
returnLocation: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
notes: string
|
||||
dateOfBirth: string
|
||||
nationality: string
|
||||
identityDocumentNumber: string
|
||||
fullAddress: string
|
||||
driverLicense: string
|
||||
licenseIssuedAt: string
|
||||
licenseExpiry: string
|
||||
licenseCountry: string
|
||||
licenseCategory: string
|
||||
internationalLicenseNumber: string
|
||||
}
|
||||
|
||||
type AvailabilityState = {
|
||||
status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error'
|
||||
nextAvailableAt: string | null
|
||||
}
|
||||
|
||||
type AvailabilityResponse = {
|
||||
available: boolean
|
||||
availabilityStatus: string
|
||||
nextAvailableAt: string | null
|
||||
blockingReason: string | null
|
||||
}
|
||||
|
||||
type BookingResponse = {
|
||||
reservationId: string
|
||||
bookingReference?: string | null
|
||||
companyName?: string
|
||||
vehicleName?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
status?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type FunnelEventName =
|
||||
| 'booking_form_viewed'
|
||||
| 'trip_dates_selected'
|
||||
| 'contact_details_started'
|
||||
| 'driver_details_expanded'
|
||||
| 'booking_request_submitted'
|
||||
| 'booking_request_failed'
|
||||
| 'booking_request_success'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Request this car in 1 minute',
|
||||
subtitle: 'Send the essentials now. Driver documents can be added later if the company needs them.',
|
||||
tripDetails: 'Trip details',
|
||||
contactDetails: 'Contact details',
|
||||
optionalDetails: 'Driver details, optional now',
|
||||
optionalDetailsHint: 'Skip this unless you want to speed up final approval. The company can collect it after confirming availability.',
|
||||
showDriverDetails: 'Add driver details now',
|
||||
hideDriverDetails: 'Hide driver details',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
dateOfBirth: 'Date of birth',
|
||||
nationality: 'Nationality',
|
||||
identityDocumentNumber: 'CIN / Passport number',
|
||||
fullAddress: 'Full address',
|
||||
driverLicense: 'License number',
|
||||
licenseIssuedAt: 'Issue date',
|
||||
licenseExpiry: 'Expiry date',
|
||||
licenseCountry: 'Country of issue',
|
||||
licenseCategory: 'License category',
|
||||
internationalLicenseNumber: 'International permit number',
|
||||
pickupLocation: 'Pick-up location',
|
||||
returnLocation: 'Drop-off location',
|
||||
sameDropoff: 'Same drop-off',
|
||||
differentDropoff: 'Different drop-off',
|
||||
sameAsPickup: 'Return to the same location',
|
||||
startDate: 'Pick-up date',
|
||||
endDate: 'Return date',
|
||||
notes: 'Anything the company should know? Optional.',
|
||||
submit: 'Send booking request',
|
||||
submitting: 'Sending…',
|
||||
successTitle: 'Request sent.',
|
||||
successBody: 'This first step is a request, not an instant booking. Here is what happens next.',
|
||||
bookingReference: 'Booking reference',
|
||||
pendingStatus: 'Pending',
|
||||
invalidDates: 'Return date must be after pick-up date.',
|
||||
required: 'Add your trip dates, name, email, and phone.',
|
||||
perDay: '/ day',
|
||||
estimatedTotal: 'Estimated total',
|
||||
days: 'day(s)',
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
locationRequired: 'Select the required pick-up and drop-off locations.',
|
||||
checkingAvailability: 'Checking availability for these dates…',
|
||||
likelyAvailable: 'Likely available for these dates. Final confirmation still depends on the rental company.',
|
||||
unavailableWithDate: 'Unavailable for these dates. Next likely availability: {date}.',
|
||||
unavailableWithoutDate: 'Unavailable for these dates. Choose different trip dates to continue.',
|
||||
availabilityError: 'Availability could not be checked right now. You can still try again in a moment.',
|
||||
nextStepRequestSent: 'Request sent',
|
||||
nextStepRequestSentBody: 'The company receives your dates and contact details first.',
|
||||
nextStepAvailability: 'Company confirms availability',
|
||||
nextStepAvailabilityBody: 'They review the request and accept it if the car is still open.',
|
||||
nextStepDocuments: 'Driver documents if needed',
|
||||
nextStepDocumentsBody: 'License and identity details can be collected after the company accepts the request.',
|
||||
nextStepPayment: 'Payment or deposit',
|
||||
nextStepPaymentBody: 'Any required payment is handled only after availability is confirmed.',
|
||||
nextStepConfirmed: 'Booking confirmed',
|
||||
nextStepConfirmedBody: 'The booking is final once availability, documents, and payment are all cleared.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Demander cette voiture en 1 minute',
|
||||
subtitle: 'Envoyez l’essentiel maintenant. Les documents conducteur peuvent être ajoutés plus tard si l’agence les demande.',
|
||||
tripDetails: 'Détails du trajet',
|
||||
contactDetails: 'Coordonnées',
|
||||
optionalDetails: 'Détails conducteur, optionnels maintenant',
|
||||
optionalDetailsHint: 'Ignorez cette partie sauf si vous voulez accélérer la validation finale. L’agence pourra les demander après confirmation de disponibilité.',
|
||||
showDriverDetails: 'Ajouter les détails conducteur',
|
||||
hideDriverDetails: 'Masquer les détails conducteur',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'E-mail',
|
||||
phone: 'Téléphone',
|
||||
dateOfBirth: 'Date de naissance',
|
||||
nationality: 'Nationalité',
|
||||
identityDocumentNumber: 'N° CIN / passeport',
|
||||
fullAddress: 'Adresse complète',
|
||||
driverLicense: 'Numéro du permis',
|
||||
licenseIssuedAt: 'Date de délivrance',
|
||||
licenseExpiry: 'Date d’expiration',
|
||||
licenseCountry: 'Pays de délivrance',
|
||||
licenseCategory: 'Catégorie du permis',
|
||||
internationalLicenseNumber: 'N° de permis international',
|
||||
pickupLocation: 'Lieu de départ',
|
||||
returnLocation: 'Lieu de retour',
|
||||
sameDropoff: 'Même retour',
|
||||
differentDropoff: 'Retour différent',
|
||||
sameAsPickup: 'Retour au même lieu',
|
||||
startDate: 'Date de départ',
|
||||
endDate: 'Date de retour',
|
||||
notes: 'Une information utile pour l’agence ? Optionnel.',
|
||||
submit: 'Envoyer la demande',
|
||||
submitting: 'Envoi…',
|
||||
successTitle: 'Demande envoyée.',
|
||||
successBody: 'Cette première étape reste une demande, pas une réservation instantanée. Voici la suite.',
|
||||
bookingReference: 'Référence de réservation',
|
||||
pendingStatus: 'En attente',
|
||||
invalidDates: 'La date de retour doit être après la date de départ.',
|
||||
required: 'Ajoutez vos dates, votre nom, votre e-mail et votre téléphone.',
|
||||
perDay: '/ jour',
|
||||
estimatedTotal: 'Total estimé',
|
||||
days: 'jour(s)',
|
||||
genericError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
locationRequired: 'Sélectionnez les lieux de départ et de retour requis.',
|
||||
checkingAvailability: 'Vérification de la disponibilité pour ces dates…',
|
||||
likelyAvailable: 'Probablement disponible pour ces dates. La confirmation finale dépend encore de l’agence.',
|
||||
unavailableWithDate: 'Indisponible pour ces dates. Prochaine disponibilité probable : {date}.',
|
||||
unavailableWithoutDate: 'Indisponible pour ces dates. Choisissez d’autres dates pour continuer.',
|
||||
availabilityError: 'La disponibilité ne peut pas être vérifiée pour le moment. Réessayez dans un instant.',
|
||||
nextStepRequestSent: 'Demande envoyée',
|
||||
nextStepRequestSentBody: 'L’agence reçoit d’abord vos dates et vos coordonnées.',
|
||||
nextStepAvailability: 'Confirmation de disponibilité',
|
||||
nextStepAvailabilityBody: 'L’agence vérifie la demande et l’accepte si la voiture est toujours libre.',
|
||||
nextStepDocuments: 'Documents conducteur si nécessaires',
|
||||
nextStepDocumentsBody: 'Le permis et les pièces d’identité peuvent être demandés après l’acceptation de la demande.',
|
||||
nextStepPayment: 'Paiement ou dépôt',
|
||||
nextStepPaymentBody: 'Le paiement demandé intervient seulement après la confirmation de disponibilité.',
|
||||
nextStepConfirmed: 'Réservation confirmée',
|
||||
nextStepConfirmedBody: 'La réservation devient finale une fois la disponibilité, les documents et le paiement validés.',
|
||||
},
|
||||
ar: {
|
||||
title: 'اطلب هذه السيارة خلال دقيقة',
|
||||
subtitle: 'أرسل المعلومات الأساسية الآن. يمكن إضافة وثائق السائق لاحقاً إذا احتاجت الشركة إليها.',
|
||||
tripDetails: 'تفاصيل الرحلة',
|
||||
contactDetails: 'بيانات التواصل',
|
||||
optionalDetails: 'بيانات السائق، اختيارية الآن',
|
||||
optionalDetailsHint: 'تجاوز هذا الجزء إلا إذا أردت تسريع الموافقة النهائية. يمكن للشركة طلبه بعد تأكيد التوفر.',
|
||||
showDriverDetails: 'إضافة بيانات السائق الآن',
|
||||
hideDriverDetails: 'إخفاء بيانات السائق',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
dateOfBirth: 'تاريخ الميلاد',
|
||||
nationality: 'الجنسية',
|
||||
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
|
||||
fullAddress: 'العنوان الكامل',
|
||||
driverLicense: 'رقم الرخصة',
|
||||
licenseIssuedAt: 'تاريخ الإصدار',
|
||||
licenseExpiry: 'تاريخ الانتهاء',
|
||||
licenseCountry: 'بلد الإصدار',
|
||||
licenseCategory: 'فئة الرخصة',
|
||||
internationalLicenseNumber: 'رقم الرخصة الدولية',
|
||||
pickupLocation: 'موقع الاستلام',
|
||||
returnLocation: 'موقع الإرجاع',
|
||||
sameDropoff: 'نفس موقع الإرجاع',
|
||||
differentDropoff: 'موقع إرجاع مختلف',
|
||||
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
|
||||
startDate: 'تاريخ الاستلام',
|
||||
endDate: 'تاريخ الإرجاع',
|
||||
notes: 'أي معلومة تريد إبلاغ الشركة بها؟ اختياري.',
|
||||
submit: 'إرسال طلب الحجز',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
successTitle: 'تم إرسال الطلب.',
|
||||
successBody: 'هذه الخطوة الأولى عبارة عن طلب وليست حجزاً فورياً. هذه هي الخطوات التالية.',
|
||||
bookingReference: 'مرجع الحجز',
|
||||
pendingStatus: 'قيد المراجعة',
|
||||
invalidDates: 'يجب أن يكون تاريخ الإرجاع بعد تاريخ الاستلام.',
|
||||
required: 'أضف تواريخ الرحلة والاسم والبريد والهاتف.',
|
||||
perDay: '/ يوم',
|
||||
estimatedTotal: 'الإجمالي التقديري',
|
||||
days: 'يوم',
|
||||
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
locationRequired: 'اختر مواقع الاستلام والإرجاع المطلوبة.',
|
||||
checkingAvailability: 'جارٍ التحقق من التوفر لهذه التواريخ…',
|
||||
likelyAvailable: 'السيارة متاحة غالباً لهذه التواريخ، لكن التأكيد النهائي يعود إلى شركة التأجير.',
|
||||
unavailableWithDate: 'غير متاحة لهذه التواريخ. أقرب توفر متوقع: {date}.',
|
||||
unavailableWithoutDate: 'غير متاحة لهذه التواريخ. اختر تواريخ مختلفة للمتابعة.',
|
||||
availabilityError: 'تعذر التحقق من التوفر الآن. حاول مرة أخرى بعد قليل.',
|
||||
nextStepRequestSent: 'تم إرسال الطلب',
|
||||
nextStepRequestSentBody: 'تتلقى الشركة أولاً التواريخ وبيانات التواصل.',
|
||||
nextStepAvailability: 'تأكيد التوفر',
|
||||
nextStepAvailabilityBody: 'تراجع الشركة الطلب وتقبله إذا كانت السيارة ما زالت متاحة.',
|
||||
nextStepDocuments: 'وثائق السائق عند الحاجة',
|
||||
nextStepDocumentsBody: 'يمكن طلب الرخصة ووثائق الهوية بعد قبول الطلب.',
|
||||
nextStepPayment: 'الدفع أو العربون',
|
||||
nextStepPaymentBody: 'أي دفعة مطلوبة تتم فقط بعد تأكيد التوفر.',
|
||||
nextStepConfirmed: 'تأكيد الحجز',
|
||||
nextStepConfirmedBody: 'يصبح الحجز نهائياً بعد اعتماد التوفر والوثائق والدفع.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const emptyFields: BookingFields = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
pickupLocation: '',
|
||||
dropoffMode: 'same',
|
||||
returnLocation: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
notes: '',
|
||||
dateOfBirth: '',
|
||||
nationality: '',
|
||||
identityDocumentNumber: '',
|
||||
fullAddress: '',
|
||||
driverLicense: '',
|
||||
licenseIssuedAt: '',
|
||||
licenseExpiry: '',
|
||||
licenseCountry: '',
|
||||
licenseCategory: '',
|
||||
internationalLicenseNumber: '',
|
||||
}
|
||||
|
||||
export function calculateBookingTotalDays(startDate: string, endDate: string): number {
|
||||
if (!startDate || !endDate) return 0
|
||||
return Math.max(0, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86_400_000))
|
||||
}
|
||||
|
||||
export function resolveReturnLocation({
|
||||
dropoffMode,
|
||||
returnLocation,
|
||||
pickupLocation,
|
||||
}: {
|
||||
dropoffMode: DropoffMode
|
||||
returnLocation: string
|
||||
pickupLocation: string
|
||||
}): string | undefined {
|
||||
if (dropoffMode === 'different') return returnLocation || undefined
|
||||
return pickupLocation || undefined
|
||||
}
|
||||
|
||||
export function normalizeOptionalText(value: string): string | undefined {
|
||||
const trimmed = value.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
function interpolate(template: string, replacements: Record<string, string>) {
|
||||
return Object.entries(replacements).reduce(
|
||||
(current, [key, value]) => current.replace(`{${key}}`, value),
|
||||
template,
|
||||
)
|
||||
}
|
||||
|
||||
function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') {
|
||||
return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function getBookingSessionId() {
|
||||
if (typeof window === 'undefined') return 'server'
|
||||
|
||||
const existing = window.sessionStorage.getItem('carplace-booking-session-id')
|
||||
if (existing) return existing
|
||||
|
||||
const created =
|
||||
typeof window.crypto?.randomUUID === 'function'
|
||||
? window.crypto.randomUUID()
|
||||
: `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
window.sessionStorage.setItem('carplace-booking-session-id', created)
|
||||
return created
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-red-500">*</span>
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
required?: boolean
|
||||
type?: string
|
||||
textarea?: boolean
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange, required = false, type = 'text', textarea = false }: FieldProps) {
|
||||
const className = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500'
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">
|
||||
{label} {required ? <RequiredMark /> : null}
|
||||
</label>
|
||||
{textarea ? (
|
||||
<textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} className={className} />
|
||||
) : (
|
||||
<input required={required} type={type} value={value} onChange={(e) => onChange(e.target.value)} className={className} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BookingForm({
|
||||
vehicleId,
|
||||
companySlug,
|
||||
dailyRate,
|
||||
pickupLocations,
|
||||
allowDifferentDropoff,
|
||||
dropoffLocations,
|
||||
}: Props) {
|
||||
const { language } = useCarplacePreferences()
|
||||
const t = copy[language]
|
||||
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
|
||||
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
|
||||
|
||||
const [fields, setFields] = useState<BookingFields>({
|
||||
...emptyFields,
|
||||
pickupLocation: pickupOptions[0] ?? '',
|
||||
returnLocation: dropoffOptions[0] ?? '',
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [successBooking, setSuccessBooking] = useState<BookingResponse | null>(null)
|
||||
const [availability, setAvailability] = useState<AvailabilityState>({ status: 'idle', nextAvailableAt: null })
|
||||
|
||||
const totalDays = calculateBookingTotalDays(fields.startDate, fields.endDate)
|
||||
const sessionIdRef = useRef<string>('')
|
||||
const startedAtRef = useRef<number>(Date.now())
|
||||
const trackedRef = useRef({
|
||||
viewed: false,
|
||||
tripDates: false,
|
||||
contactStarted: false,
|
||||
})
|
||||
|
||||
if (!sessionIdRef.current) {
|
||||
sessionIdRef.current = getBookingSessionId()
|
||||
}
|
||||
|
||||
function buildUnavailableMessage(nextAvailableAt: string | null) {
|
||||
return nextAvailableAt
|
||||
? interpolate(t.unavailableWithDate, { date: formatLocalizedDate(nextAvailableAt, language) })
|
||||
: t.unavailableWithoutDate
|
||||
}
|
||||
|
||||
async function trackEvent(eventName: FunnelEventName, metadata?: Record<string, string | number | boolean | null>) {
|
||||
await carplacePost('/carplace/events', {
|
||||
eventName,
|
||||
companySlug,
|
||||
vehicleId,
|
||||
sessionId: sessionIdRef.current,
|
||||
path: typeof window === 'undefined' ? undefined : window.location.pathname,
|
||||
metadata,
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) {
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
const result = await carplaceFetch<AvailabilityResponse>(
|
||||
`/carplace/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
|
||||
signal ? { signal } : undefined,
|
||||
)
|
||||
|
||||
setAvailability({
|
||||
status: result.available ? 'available' : 'unavailable',
|
||||
nextAvailableAt: result.nextAvailableAt,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function update<K extends keyof BookingFields>(key: K, value: BookingFields[K]) {
|
||||
setFields((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedRef.current.viewed) return
|
||||
trackedRef.current.viewed = true
|
||||
void trackEvent('booking_form_viewed', {
|
||||
allowDifferentDropoff,
|
||||
hasPickupLocations: pickupOptions.length > 0,
|
||||
hasDropoffLocations: dropoffOptions.length > 0,
|
||||
})
|
||||
}, [allowDifferentDropoff, dropoffOptions.length, pickupOptions.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedRef.current.contactStarted) return
|
||||
if (!fields.firstName.trim() && !fields.lastName.trim() && !fields.email.trim() && !fields.phone.trim()) return
|
||||
|
||||
trackedRef.current.contactStarted = true
|
||||
void trackEvent('contact_details_started')
|
||||
}, [fields.email, fields.firstName, fields.lastName, fields.phone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fields.startDate || !fields.endDate) {
|
||||
setAvailability({ status: 'idle', nextAvailableAt: null })
|
||||
return
|
||||
}
|
||||
|
||||
const start = new Date(fields.startDate)
|
||||
const end = new Date(fields.endDate)
|
||||
if (end <= start) {
|
||||
setAvailability({ status: 'idle', nextAvailableAt: null })
|
||||
return
|
||||
}
|
||||
|
||||
if (!trackedRef.current.tripDates) {
|
||||
trackedRef.current.tripDates = true
|
||||
void trackEvent('trip_dates_selected', { totalDays: calculateBookingTotalDays(fields.startDate, fields.endDate) })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setAvailability((current) => ({ ...current, status: 'checking' }))
|
||||
|
||||
void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => {
|
||||
if (controller.signal.aborted) return
|
||||
if (err instanceof CarplaceApiError && err.code === 'invalid_dates') {
|
||||
setAvailability({ status: 'idle', nextAvailableAt: null })
|
||||
return
|
||||
}
|
||||
setAvailability({ status: 'error', nextAvailableAt: null })
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [companySlug, fields.endDate, fields.startDate, vehicleId])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!fields.firstName.trim() || !fields.lastName.trim() || !fields.email.trim() || !fields.phone.trim() || !fields.startDate || !fields.endDate) {
|
||||
setError(t.required)
|
||||
return
|
||||
}
|
||||
|
||||
if ((pickupOptions.length > 0 && !fields.pickupLocation) || (fields.dropoffMode === 'different' && dropoffOptions.length > 0 && !fields.returnLocation)) {
|
||||
setError(t.locationRequired)
|
||||
return
|
||||
}
|
||||
|
||||
const start = new Date(fields.startDate)
|
||||
const end = new Date(fields.endDate)
|
||||
if (end <= start) {
|
||||
setError(t.invalidDates)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
void trackEvent('booking_request_submitted', {
|
||||
totalDays,
|
||||
})
|
||||
|
||||
const availabilityResult = await checkAvailability(fields.startDate, fields.endDate)
|
||||
if (!availabilityResult.available) {
|
||||
const message = buildUnavailableMessage(availabilityResult.nextAvailableAt)
|
||||
setError(message)
|
||||
void trackEvent('booking_request_failed', {
|
||||
reason: 'unavailable',
|
||||
nextAvailableAt: availabilityResult.nextAvailableAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const booking = await carplacePost<BookingResponse>('/carplace/reservations', {
|
||||
vehicleId,
|
||||
companySlug,
|
||||
firstName: fields.firstName.trim(),
|
||||
lastName: fields.lastName.trim(),
|
||||
email: fields.email.trim(),
|
||||
phone: fields.phone.trim(),
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
pickupLocation: fields.pickupLocation || undefined,
|
||||
returnLocation: resolveReturnLocation({
|
||||
dropoffMode: fields.dropoffMode,
|
||||
returnLocation: fields.returnLocation,
|
||||
pickupLocation: fields.pickupLocation,
|
||||
}),
|
||||
notes: normalizeOptionalText(fields.notes),
|
||||
language,
|
||||
})
|
||||
|
||||
setSuccessBooking(booking)
|
||||
setSuccess(true)
|
||||
void trackEvent('booking_request_success', {
|
||||
totalDays,
|
||||
elapsedMs: Date.now() - startedAtRef.current,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof CarplaceApiError && err.code === 'unavailable') {
|
||||
setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null })
|
||||
setError(buildUnavailableMessage(err.nextAvailableAt ?? null))
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t.genericError)
|
||||
}
|
||||
|
||||
void trackEvent('booking_request_failed', {
|
||||
reason: err instanceof CarplaceApiError ? err.code ?? 'request_failed' : 'request_failed',
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-950/40">
|
||||
<p className="text-base font-semibold text-green-800 dark:text-green-300">{t.successTitle}</p>
|
||||
<p className="mt-2 text-sm text-green-700 dark:text-green-400">{t.successBody}</p>
|
||||
{successBooking?.bookingReference ? (
|
||||
<div className="mt-4 rounded-2xl border border-green-200 bg-white/70 px-4 py-3 text-sm text-green-900 dark:border-green-800 dark:bg-green-950/20 dark:text-green-200">
|
||||
<p className="font-semibold">{t.bookingReference}: {successBooking.bookingReference}</p>
|
||||
<p className="mt-1 text-xs text-green-700 dark:text-green-400">
|
||||
{successBooking.status ?? t.pendingStatus}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 space-y-3">
|
||||
{[
|
||||
{ title: t.nextStepRequestSent, body: t.nextStepRequestSentBody, active: true },
|
||||
{ title: t.nextStepAvailability, body: t.nextStepAvailabilityBody },
|
||||
{ title: t.nextStepDocuments, body: t.nextStepDocumentsBody },
|
||||
{ title: t.nextStepPayment, body: t.nextStepPaymentBody },
|
||||
{ title: t.nextStepConfirmed, body: t.nextStepConfirmedBody },
|
||||
].map((step, index) => (
|
||||
<div
|
||||
key={step.title}
|
||||
className={`rounded-2xl border px-4 py-3 ${step.active ? 'border-green-300 bg-white/80 dark:border-green-700 dark:bg-green-950/20' : 'border-green-100 bg-white/50 dark:border-green-900/60 dark:bg-transparent'}`}
|
||||
>
|
||||
<p className="text-sm font-semibold text-green-900 dark:text-green-200">{index + 1}. {step.title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-green-700 dark:text-green-400">{step.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
|
||||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.tripDetails}</p>
|
||||
|
||||
{pickupOptions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.pickupLocation} <RequiredMark /></label>
|
||||
<select
|
||||
value={fields.pickupLocation}
|
||||
onChange={(e) => update('pickupLocation', e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
|
||||
>
|
||||
{pickupOptions.map((location) => <option key={location} value={location}>{location}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{allowDifferentDropoff ? (
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex rounded-full border border-stone-200 bg-white p-1 dark:border-blue-800 dark:bg-blue-900/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update('dropoffMode', 'same')}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'same' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
|
||||
>
|
||||
{t.sameDropoff}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update('dropoffMode', 'different')}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'different' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
|
||||
>
|
||||
{t.differentDropoff}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fields.dropoffMode === 'different' && dropoffOptions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.returnLocation} <RequiredMark /></label>
|
||||
<select
|
||||
value={fields.returnLocation}
|
||||
onChange={(e) => update('returnLocation', e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
|
||||
>
|
||||
{dropoffOptions.map((location) => <option key={location} value={location}>{location}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{fields.pickupLocation || t.sameAsPickup}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t.startDate} required type="date" value={fields.startDate} onChange={(value) => update('startDate', value)} />
|
||||
<Field label={t.endDate} required type="date" value={fields.endDate} onChange={(value) => update('endDate', value)} />
|
||||
</div>
|
||||
|
||||
{totalDays > 0 ? (
|
||||
<p className="text-sm text-stone-600 dark:text-stone-400">
|
||||
{t.estimatedTotal}:{' '}
|
||||
<strong className="text-stone-900 dark:text-stone-100">{formatCurrency(dailyRate * totalDays, 'MAD', language)}</strong>{' '}
|
||||
({totalDays} {t.days})
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{availability.status === 'checking' ? (
|
||||
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
|
||||
{t.checkingAvailability}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{availability.status === 'available' ? (
|
||||
<p className="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700 dark:border-green-800 dark:bg-green-950/40 dark:text-green-300">
|
||||
{t.likelyAvailable}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{availability.status === 'unavailable' ? (
|
||||
<p className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300">
|
||||
{buildUnavailableMessage(availability.nextAvailableAt)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{availability.status === 'error' ? (
|
||||
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
|
||||
{t.availabilityError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
|
||||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.contactDetails}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t.firstName} required value={fields.firstName} onChange={(value) => update('firstName', value)} />
|
||||
<Field label={t.lastName} required value={fields.lastName} onChange={(value) => update('lastName', value)} />
|
||||
</div>
|
||||
<Field label={t.email} required type="email" value={fields.email} onChange={(value) => update('email', value)} />
|
||||
<Field label={t.phone} required type="tel" value={fields.phone} onChange={(value) => update('phone', value)} />
|
||||
<Field label={t.notes} textarea value={fields.notes} onChange={(value) => update('notes', value)} />
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || availability.status === 'checking'}
|
||||
className="w-full rounded-full bg-orange-600 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||||
>
|
||||
{submitting ? t.submitting : t.submit}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
type LocaleOption = {
|
||||
value: 'en' | 'fr' | 'ar'
|
||||
label: string
|
||||
flag: string
|
||||
}
|
||||
|
||||
type FooterItem = {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
export default function CarplaceFooter({
|
||||
primaryItems,
|
||||
secondaryItems,
|
||||
localeLabel,
|
||||
rightsLabel,
|
||||
localeOptions,
|
||||
currentLocale,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
primaryItems: FooterItem[]
|
||||
secondaryItems: FooterItem[]
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
localeOptions: LocaleOption[]
|
||||
currentLocale: LocaleOption
|
||||
onSelectLanguage: (language: LocaleOption['value']) => void
|
||||
}) {
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 dark:text-stone-300">
|
||||
<div className="shell flex flex-col items-center gap-5 text-center">
|
||||
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{primaryItems.map((item, index) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
{index < primaryItems.length - 1 ? (
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{secondaryItems.map((item) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={localeMenuRef} className="relative px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocaleMenuOpen((open) => !open)}
|
||||
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
|
||||
<span>{localeLabel}</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
{localeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {rightsLabel}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterNavItem({ item }: { item: FooterItem }) {
|
||||
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link href={item.href} className={className}>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={className}>{item.label}</span>
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDashboardSignInHref,
|
||||
companyInitial,
|
||||
localeMenuPositionClass,
|
||||
ownerWorkspaceHref,
|
||||
} from './CarplaceHeader'
|
||||
|
||||
describe('CarplaceHeader helpers', () => {
|
||||
it('builds dashboard sign-in links with language and theme query parameters', () => {
|
||||
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
|
||||
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
|
||||
)
|
||||
expect(buildDashboardSignInHref('/dashboard', 'ar', 'light')).toBe('/dashboard/sign-in?lang=ar&theme=light')
|
||||
})
|
||||
|
||||
it('keeps the locale dropdown anchored to the readable side for RTL and LTR languages', () => {
|
||||
expect(localeMenuPositionClass('ar')).toBe('right-0 sm:right-0')
|
||||
expect(localeMenuPositionClass('en')).toBe('left-0 sm:left-auto sm:right-0')
|
||||
expect(localeMenuPositionClass('fr')).toBe('left-0 sm:left-auto sm:right-0')
|
||||
})
|
||||
|
||||
it('routes existing companies to their workspace and new owners to sign-up', () => {
|
||||
expect(ownerWorkspaceHref('https://dashboard.example.com', 'Atlas Cars')).toBe('https://dashboard.example.com')
|
||||
expect(ownerWorkspaceHref('https://dashboard.example.com', null)).toBe('https://dashboard.example.com/sign-up')
|
||||
})
|
||||
|
||||
it('normalizes company initials for the owner workspace pill', () => {
|
||||
expect(companyInitial('atlas cars')).toBe('A')
|
||||
expect(companyInitial(' زاكورة كار ')).toBe('ز')
|
||||
})
|
||||
})
|
||||
@@ -1,231 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
export type Language = 'en' | 'fr' | 'ar'
|
||||
|
||||
type Dictionary = {
|
||||
carplace: string
|
||||
explore: string
|
||||
signIn: string
|
||||
ownerSignIn: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
}
|
||||
|
||||
type LanguageMeta = {
|
||||
value: Language
|
||||
flag: string
|
||||
shortLabel: string
|
||||
}
|
||||
|
||||
|
||||
export function buildDashboardSignInHref(dashboardUrl: string, language: Language, theme: Theme): string {
|
||||
const signInParams = new URLSearchParams()
|
||||
signInParams.set('lang', language)
|
||||
signInParams.set('theme', theme)
|
||||
return `${dashboardUrl}/sign-in?${signInParams.toString()}`
|
||||
}
|
||||
|
||||
export function localeMenuPositionClass(language: Language): string {
|
||||
return language === 'ar' ? 'right-0 sm:right-0' : 'left-0 sm:left-auto sm:right-0'
|
||||
}
|
||||
|
||||
export function ownerWorkspaceHref(dashboardUrl: string, companyName: string | null): string {
|
||||
return companyName ? dashboardUrl : `${dashboardUrl}/sign-up`
|
||||
}
|
||||
|
||||
export function companyInitial(companyName: string): string {
|
||||
return companyName.trim().charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
export default function CarplaceHeader({
|
||||
dict,
|
||||
theme,
|
||||
setTheme,
|
||||
companyName,
|
||||
dashboardUrl,
|
||||
currentLanguage,
|
||||
localeOptions,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
dict: Dictionary
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
companyName: string | null
|
||||
dashboardUrl: string
|
||||
currentLanguage: LanguageMeta
|
||||
localeOptions: LanguageMeta[]
|
||||
onSelectLanguage: (language: Language) => void
|
||||
}) {
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, label: dict.light },
|
||||
{ value: 'dark' as const, label: dict.dark },
|
||||
]
|
||||
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
|
||||
const menuPositionClass = localeMenuPositionClass(currentLanguage.value)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
function toggleLocaleMenu() {
|
||||
setThemeMenuOpen(false)
|
||||
setLocaleMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
function toggleThemeMenu() {
|
||||
setLocaleMenuOpen(false)
|
||||
setThemeMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
const signInHref = buildDashboardSignInHref(dashboardUrl, currentLanguage.value, theme)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
|
||||
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
|
||||
<Image
|
||||
src="/rentaldrivego.png"
|
||||
alt="RentalDriveGo"
|
||||
width={36}
|
||||
height={36}
|
||||
priority
|
||||
unoptimized
|
||||
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
|
||||
/>
|
||||
<span>RentalDriveGo</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
|
||||
<nav className="flex items-center gap-0.5 overflow-x-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.carplace}
|
||||
</Link>
|
||||
<Link
|
||||
href="/explore"
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.explore}
|
||||
</Link>
|
||||
<a
|
||||
href={signInHref}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.signIn}
|
||||
</a>
|
||||
{companyName ? (
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-blue-950/20">
|
||||
{companyInitial(companyName)}
|
||||
</span>
|
||||
{companyName}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={ownerWorkspaceHref(dashboardUrl, companyName)}
|
||||
className="ml-1 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{dict.ownerSignIn}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
|
||||
<div ref={localeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLocaleMenu}
|
||||
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
|
||||
{localeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.shortLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
|
||||
<div ref={themeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleThemeMenu}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
|
||||
aria-expanded={themeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{themeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
|
||||
{themeOptions
|
||||
.filter((option) => option.value !== theme)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(option.value)
|
||||
setThemeMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './CarplaceShell'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('CarplaceShell auth helpers', () => {
|
||||
it('does not report an employee profile when the browser cache is empty', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasCachedEmployeeProfile()).toBe(false)
|
||||
})
|
||||
|
||||
it('detects the cached employee profile marker written by dashboard sign-in', () => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasCachedEmployeeProfile()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the cached employee profile marker', () => {
|
||||
const removeItem = vi.fn()
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
removeItem,
|
||||
},
|
||||
})
|
||||
|
||||
clearCachedEmployeeProfile()
|
||||
|
||||
expect(removeItem).toHaveBeenCalledWith('employee_profile')
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
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: '/app-privacy-en' })
|
||||
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
|
||||
})
|
||||
|
||||
it('returns French labels while preserving canonical footer slugs', () => {
|
||||
const content = getFooterContent('fr')
|
||||
|
||||
expect(content.localeLabel).toBe('Europe (French)')
|
||||
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/footer/terms-of-service' })
|
||||
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
|
||||
})
|
||||
|
||||
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: '/app-privacy-ar' })
|
||||
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
|
||||
})
|
||||
})
|
||||
@@ -1,364 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isCarplaceLanguage, type CarplaceLanguage } from '@/lib/i18n'
|
||||
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
type Dictionary = {
|
||||
carplace: string
|
||||
explore: string
|
||||
signIn: string
|
||||
ownerSignIn: string
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
preferences: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<CarplaceLanguage, Dictionary> = {
|
||||
en: {
|
||||
carplace: 'Home',
|
||||
explore: 'Carplace',
|
||||
signIn: 'Sign in',
|
||||
ownerSignIn: 'Create Agency Space',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
preferences: 'Carplace preferences',
|
||||
},
|
||||
fr: {
|
||||
carplace: 'Accueil',
|
||||
explore: 'Carplace',
|
||||
signIn: 'Connexion',
|
||||
ownerSignIn: "Créer un espace d'agence",
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
preferences: 'Préférences carplace',
|
||||
},
|
||||
ar: {
|
||||
carplace: 'الرئيسية',
|
||||
explore: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
ownerSignIn: 'إنشاء مساحة الوكالة',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
preferences: 'تفضيلات السوق',
|
||||
},
|
||||
}
|
||||
|
||||
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
|
||||
export const localeOptions: Array<{ value: CarplaceLanguage; label: string; flag: string }> = [
|
||||
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
|
||||
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
|
||||
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
|
||||
]
|
||||
|
||||
export function getFooterContent(language: CarplaceLanguage): {
|
||||
primary: Array<{ label: string; href?: string }>
|
||||
secondary: Array<{ label: string; href?: string }>
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
} {
|
||||
switch (language) {
|
||||
case 'fr':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
|
||||
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
|
||||
{ label: 'Sécurité', href: footerPageHref.security },
|
||||
{ label: 'Conformité', href: footerPageHref.compliance },
|
||||
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
|
||||
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
||||
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
localeLabel: 'Europe (French)',
|
||||
rightsLabel: 'Tous droits réservés.',
|
||||
}
|
||||
case 'ar':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'من نحن', href: footerPageHref['about-us'] },
|
||||
{ label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] },
|
||||
{ label: 'الأمان', href: footerPageHref.security },
|
||||
{ label: 'الامتثال', href: footerPageHref.compliance },
|
||||
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
|
||||
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
|
||||
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
localeLabel: 'North Africa (Arabic)',
|
||||
rightsLabel: 'جميع الحقوق محفوظة.',
|
||||
}
|
||||
case 'en':
|
||||
default:
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'About Us', href: footerPageHref['about-us'] },
|
||||
{ label: 'Terms of Service', href: footerPageHref['terms-of-service'] },
|
||||
{ label: 'Security', href: footerPageHref.security },
|
||||
{ label: 'Compliance', href: footerPageHref.compliance },
|
||||
{ label: 'Privacy Policy', href: appPrivacyHref.en },
|
||||
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
||||
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
|
||||
{ label: 'General Conditions', href: footerPageHref['general-conditions'] },
|
||||
],
|
||||
localeLabel: 'Global (English)',
|
||||
rightsLabel: 'All rights reserved.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCachedEmployeeProfile(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
try {
|
||||
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCachedEmployeeProfile() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function detectBrowserLanguage(): string | null {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const langs = Array.from(navigator.languages ?? [navigator.language])
|
||||
for (const lang of langs) {
|
||||
const code = lang.split('-')[0].toLowerCase()
|
||||
if (code === 'fr' || code === 'ar' || code === 'en') return code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type PreferencesContextValue = {
|
||||
language: CarplaceLanguage
|
||||
theme: Theme
|
||||
dict: Dictionary
|
||||
companyName: string | null
|
||||
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 apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const [language, setLanguageState] = useState<CarplaceLanguage>(initialLanguage)
|
||||
const [theme, setThemeState] = useState<Theme>(initialTheme)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const previousLanguage = useRef<CarplaceLanguage>(initialLanguage)
|
||||
const [companyName, setCompanyName] = useState<string | null>(null)
|
||||
|
||||
function applyLanguage(nextLanguage: CarplaceLanguage) {
|
||||
if (nextLanguage === language) return
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.lang = nextLanguage
|
||||
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
|
||||
try {
|
||||
sessionStorage.setItem('carplace-language', nextLanguage)
|
||||
} catch {}
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['carplace-language'])
|
||||
}
|
||||
|
||||
setLanguageState(nextLanguage)
|
||||
}
|
||||
|
||||
function applyTheme(nextTheme: Theme) {
|
||||
if (nextTheme === theme) return
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
|
||||
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
|
||||
document.body.dataset.theme = nextTheme
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['carplace-theme'])
|
||||
}
|
||||
|
||||
setThemeState(nextTheme)
|
||||
}
|
||||
|
||||
async function syncCompanyBrand() {
|
||||
if (!hasCachedEmployeeProfile()) {
|
||||
setCompanyName(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/companies/me/brand`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearCachedEmployeeProfile()
|
||||
}
|
||||
setCompanyName(null)
|
||||
return
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
const name = json?.data?.displayName ?? json?.displayName ?? null
|
||||
setCompanyName(name)
|
||||
} catch {
|
||||
setCompanyName(null)
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionLanguage(): CarplaceLanguage | null {
|
||||
try {
|
||||
const val = sessionStorage.getItem('carplace-language')
|
||||
return isCarplaceLanguage(val) ? val : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function syncScopedPreferencesForSession() {
|
||||
const sessionLang = readSessionLanguage()
|
||||
if (sessionLang) {
|
||||
if (sessionLang !== language) setLanguageState(sessionLang)
|
||||
} else {
|
||||
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
|
||||
if (isCarplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
|
||||
setLanguageState(scopedLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
|
||||
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
|
||||
setThemeState(scopedTheme)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['carplace-theme'])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sessionLang = readSessionLanguage()
|
||||
if (sessionLang) {
|
||||
setLanguageState(sessionLang)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['carplace-language'])
|
||||
} else {
|
||||
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['carplace-language'])
|
||||
if (isCarplaceLanguage(storedLanguage)) {
|
||||
setLanguageState(storedLanguage)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['carplace-language'])
|
||||
} else {
|
||||
const detected = detectBrowserLanguage()
|
||||
if (isCarplaceLanguage(detected)) {
|
||||
setLanguageState(detected)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['carplace-language'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['carplace-theme']) as Theme | null
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setThemeState(storedTheme)
|
||||
}
|
||||
|
||||
setHydrated(true)
|
||||
void syncCompanyBrand()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleWindowFocus() {
|
||||
syncScopedPreferencesForSession()
|
||||
void syncCompanyBrand()
|
||||
}
|
||||
|
||||
function handleAuthMessage(event: MessageEvent) {
|
||||
if (!event.data || typeof event.data !== 'object') return
|
||||
|
||||
const message = event.data as { type?: string }
|
||||
|
||||
if (message.type === 'rentaldrivego:employee-login' || message.type === 'rentaldrivego:employee-logout') {
|
||||
syncScopedPreferencesForSession()
|
||||
void syncCompanyBrand()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus)
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
document.addEventListener('visibilitychange', handleWindowFocus)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
window.removeEventListener('message', handleAuthMessage)
|
||||
document.removeEventListener('visibilitychange', handleWindowFocus)
|
||||
}
|
||||
}, [language, theme])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
|
||||
if (!hydrated) return
|
||||
|
||||
try { sessionStorage.setItem('carplace-language', language) } catch {}
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['carplace-language'])
|
||||
|
||||
if (previousLanguage.current !== language && pathname !== '/sign-in') {
|
||||
router.refresh()
|
||||
}
|
||||
previousLanguage.current = language
|
||||
}, [hydrated, language, pathname, router])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
|
||||
document.body.dataset.theme = theme
|
||||
if (hydrated) {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['carplace-theme'])
|
||||
}
|
||||
}, [theme, hydrated])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
|
||||
[language, theme, companyName],
|
||||
)
|
||||
|
||||
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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[] {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
User,
|
||||
Bookmark,
|
||||
Bell,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Car,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCarplacePreferences } from '@/components/CarplaceShell'
|
||||
import { clearRenterSession, loadRenterProfile } from '@/lib/renter'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/renter/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/renter/profile', key: 'profile', icon: User },
|
||||
{ href: '/renter/saved-companies', key: 'savedCompanies', icon: Bookmark },
|
||||
{ href: '/renter/notifications', key: 'notifications', icon: Bell },
|
||||
] as const
|
||||
|
||||
const dicts = {
|
||||
en: {
|
||||
dashboard: 'Dashboard',
|
||||
profile: 'Profile',
|
||||
savedCompanies: 'Saved companies',
|
||||
notifications: 'Notifications',
|
||||
signOut: 'Sign out',
|
||||
renterPortal: 'Renter Portal',
|
||||
rights: 'All rights reserved.',
|
||||
},
|
||||
fr: {
|
||||
dashboard: 'Tableau de bord',
|
||||
profile: 'Profil',
|
||||
savedCompanies: 'Entreprises sauvegardées',
|
||||
notifications: 'Notifications',
|
||||
signOut: 'Déconnexion',
|
||||
renterPortal: 'Espace client',
|
||||
rights: 'Tous droits réservés.',
|
||||
},
|
||||
ar: {
|
||||
dashboard: 'لوحة التحكم',
|
||||
profile: 'الملف الشخصي',
|
||||
savedCompanies: 'الشركات المحفوظة',
|
||||
notifications: 'الإشعارات',
|
||||
signOut: 'تسجيل الخروج',
|
||||
renterPortal: 'بوابة المستأجر',
|
||||
rights: 'جميع الحقوق محفوظة.',
|
||||
},
|
||||
} as const
|
||||
|
||||
function computeInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) return 'R'
|
||||
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
|
||||
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
|
||||
export default function RenterShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { language } = useCarplacePreferences()
|
||||
const dict = dicts[language]
|
||||
const isRtl = language === 'ar'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [userInitials, setUserInitials] = useState('R')
|
||||
const [userName, setUserName] = useState<string | null>(null)
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadRenterProfile()
|
||||
.then((profile) => {
|
||||
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
|
||||
setUserName(fullName || profile.email.split('@')[0])
|
||||
setUserEmail(profile.email)
|
||||
setUserInitials(computeInitials(fullName || profile.email))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function signOut() {
|
||||
clearRenterSession()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const isActive = (item: (typeof NAV_ITEMS)[number]) => {
|
||||
if ('exact' in item && item.exact) return pathname === item.href
|
||||
return pathname.startsWith(item.href)
|
||||
}
|
||||
|
||||
const activeNav = NAV_ITEMS.find((item) => isActive(item))
|
||||
const pageTitle = activeNav ? (dict[activeNav.key as keyof typeof dict] as string) : dict.dashboard
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen">
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-blue-950/50 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
className={[
|
||||
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-blue-950 text-white shadow-lg lg:hidden',
|
||||
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
|
||||
].join(' ')}
|
||||
>
|
||||
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={[
|
||||
'fixed inset-y-0 z-40 flex w-64 flex-col bg-blue-950 transition-transform duration-300',
|
||||
isRtl ? 'right-0' : 'left-0',
|
||||
'lg:translate-x-0',
|
||||
open ? 'translate-x-0' : isRtl ? 'translate-x-full' : '-translate-x-full',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-blue-900/50 px-6 py-5">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-orange-500 overflow-hidden">
|
||||
<Car className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="truncate text-sm font-bold tracking-wide text-white">
|
||||
{dict.renterPortal}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{dict[item.key as keyof typeof dict] as string}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-stone-800 px-3 py-4">
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
|
||||
{userInitials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{userName && (
|
||||
<p className="truncate text-sm font-medium text-white">{userName}</p>
|
||||
)}
|
||||
{userEmail && (
|
||||
<p className="truncate text-xs text-stone-400">{userEmail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-blue-900/40 hover:text-white"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{dict.signOut}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
className={[
|
||||
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-blue-950 text-white shadow-lg lg:hidden',
|
||||
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
|
||||
].join(' ')}
|
||||
>
|
||||
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'flex min-h-screen min-w-0 flex-1 flex-col',
|
||||
isRtl ? 'lg:mr-64' : 'lg:ml-64',
|
||||
].join(' ')}
|
||||
>
|
||||
<header className="flex h-16 items-center justify-between border-b border-stone-200/80 bg-white/80 px-6 backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<h1 className="text-lg font-semibold text-stone-900 dark:text-stone-100">{pageTitle}</h1>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
|
||||
{userInitials}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto py-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-stone-200/80 bg-white/80 px-6 py-4 backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<p className="text-center text-xs text-stone-400 dark:text-stone-500">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {dict.rights}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildFrameUrl, FRAME_HEIGHT, getDefaultFramePath } from './WorkspaceFrame'
|
||||
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function stubBrowser({ cookie = '', origin = 'https://market.example' }: { cookie?: string; origin?: string } = {}) {
|
||||
const parsed = new URL(origin)
|
||||
vi.stubGlobal('window', {
|
||||
location: { origin, hostname: parsed.hostname, protocol: parsed.protocol, replace: vi.fn() },
|
||||
})
|
||||
vi.stubGlobal('document', { cookie })
|
||||
}
|
||||
|
||||
describe('WorkspaceFrame helpers', () => {
|
||||
it('uses the dashboard sign-in path during server rendering', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
|
||||
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
|
||||
expect(buildFrameUrl('https://dashboard.example/dashboard', '/dashboard/sign-in')).toBe('/dashboard/sign-in')
|
||||
})
|
||||
|
||||
it('keeps users with an employee token on the embedded sign-in path until the dashboard confirms login', () => {
|
||||
stubBrowser({ cookie: 'other=1; employee_session=token_123' })
|
||||
|
||||
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
|
||||
})
|
||||
|
||||
it('keeps unauthenticated visitors on the embedded sign-in path', () => {
|
||||
stubBrowser({ cookie: 'carplace-language=fr' })
|
||||
|
||||
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
|
||||
})
|
||||
|
||||
it('rewrites a configured app base to the requested embedded path and query', () => {
|
||||
stubBrowser({ origin: 'https://market.example' })
|
||||
|
||||
expect(buildFrameUrl('https://dashboard.example/dashboard', '/dashboard/reset-password?token=abc')).toBe(
|
||||
'https://market.example/dashboard/reset-password?token=abc',
|
||||
)
|
||||
})
|
||||
|
||||
it('documents the iframe height contract used by embedded dashboard surfaces', () => {
|
||||
expect(FRAME_HEIGHT).toBe('calc(100vh - 56px)')
|
||||
})
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
import { useCarplacePreferences } from './CarplaceShell'
|
||||
|
||||
export type FrameId = 'sign-in'
|
||||
|
||||
const frameConfig: Record<FrameId, { label: string; appUrl: string; path: string }> = {
|
||||
'sign-in': {
|
||||
label: 'Sign in',
|
||||
appUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
path: '/dashboard/sign-in',
|
||||
},
|
||||
}
|
||||
|
||||
export function getDefaultFramePath(target: FrameId) {
|
||||
if (typeof window === 'undefined') {
|
||||
return frameConfig[target].path
|
||||
}
|
||||
|
||||
return frameConfig[target].path
|
||||
}
|
||||
|
||||
export function buildFrameUrl(appUrl: string, path: string) {
|
||||
if (typeof window === 'undefined') return path
|
||||
|
||||
const resolvedAppUrl = resolveBrowserAppUrl(appUrl)
|
||||
const appBase = new URL(resolvedAppUrl, window.location.origin)
|
||||
const target = new URL(path, window.location.origin)
|
||||
appBase.pathname = target.pathname
|
||||
appBase.search = target.search
|
||||
appBase.hash = ''
|
||||
return appBase.toString()
|
||||
}
|
||||
|
||||
export const FRAME_HEIGHT = 'calc(100vh - 56px)'
|
||||
|
||||
export default function WorkspaceFrame({ target }: { target: FrameId }) {
|
||||
const { language, theme } = useCarplacePreferences()
|
||||
const [src, setSrc] = useState('')
|
||||
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
|
||||
const config = frameConfig[target]
|
||||
const loadingLabel = {
|
||||
en: 'Loading',
|
||||
fr: 'Chargement',
|
||||
ar: 'جارٍ تحميل',
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
setFramePath(getDefaultFramePath(target))
|
||||
}, [target])
|
||||
|
||||
useEffect(() => {
|
||||
function handleFrameMessage(event: MessageEvent) {
|
||||
if (!event.data || typeof event.data !== 'object') return
|
||||
|
||||
const message = event.data as { type?: string; path?: string }
|
||||
|
||||
if (target === 'sign-in' && message.type === 'rentaldrivego:employee-login' && typeof message.path === 'string') {
|
||||
window.location.replace(message.path)
|
||||
return
|
||||
}
|
||||
|
||||
if ((message.type === 'rentaldrivego:embedded-path' || message.type === 'rentaldrivego:employee-login') && typeof message.path === 'string') {
|
||||
setFramePath(message.path)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleFrameMessage)
|
||||
return () => window.removeEventListener('message', handleFrameMessage)
|
||||
}, [target])
|
||||
|
||||
useEffect(() => {
|
||||
if (target === 'sign-in' && src) return
|
||||
|
||||
const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath))
|
||||
nextUrl.searchParams.set('theme', theme)
|
||||
nextUrl.searchParams.set('lang', language)
|
||||
nextUrl.searchParams.set('embedded', '1')
|
||||
setSrc(nextUrl.toString())
|
||||
}, [config.appUrl, framePath, language, src, target, theme])
|
||||
|
||||
const content = src ? (
|
||||
<iframe
|
||||
title={config.label}
|
||||
src={src}
|
||||
className="block w-full border-0 bg-white dark:bg-blue-950"
|
||||
style={{ height: FRAME_HEIGHT }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center justify-center text-sm text-stone-500 dark:text-stone-300"
|
||||
style={{ height: FRAME_HEIGHT }}
|
||||
>
|
||||
{loadingLabel} {config.label}…
|
||||
</div>
|
||||
)
|
||||
|
||||
if (target === 'sign-in') {
|
||||
return <main className="min-h-[calc(100vh-56px)]">{content}</main>
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section py-6 sm:py-8 lg:py-10">
|
||||
<section className="site-panel overflow-hidden p-0">{content}</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// Public marketing footer used by the homepage and other carplace pages.
|
||||
export { default } from '@/components/CarplaceFooter'
|
||||
@@ -1,4 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// Public marketing navbar used by the homepage and other carplace pages.
|
||||
export { default } from '@/components/CarplaceHeader'
|
||||
@@ -1,15 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('carplace public page layout', () => {
|
||||
it('keeps the navbar and footer in reusable component modules', () => {
|
||||
const layoutPath = fileURLToPath(new URL('./SitePageLayout.tsx', import.meta.url))
|
||||
const source = fs.readFileSync(layoutPath, 'utf8')
|
||||
|
||||
expect(source).toContain("import SiteNavbar from './SiteNavbar'")
|
||||
expect(source).toContain("import SiteFooter from './SiteFooter'")
|
||||
expect(source).toContain('<SiteNavbar')
|
||||
expect(source).toContain('<SiteFooter')
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import SiteFooter from './SiteFooter'
|
||||
import SiteNavbar from './SiteNavbar'
|
||||
import {
|
||||
getFooterContent,
|
||||
localeOptions,
|
||||
useCarplacePreferences,
|
||||
} from '@/components/CarplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function SitePageLayout({ children }: { children: ReactNode }) {
|
||||
const { language, theme, dict, companyName, setLanguage, setTheme } = useCarplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
)
|
||||
const footerContent = getFooterContent(language)
|
||||
const available = localeOptions.filter((option) => option.value !== language)
|
||||
const current = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col" data-site-page-layout="true">
|
||||
<SiteNavbar
|
||||
dict={dict}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
companyName={companyName}
|
||||
dashboardUrl={dashboardUrl}
|
||||
currentLanguage={{
|
||||
value: current.value,
|
||||
flag: current.flag,
|
||||
shortLabel: current.value.toUpperCase(),
|
||||
}}
|
||||
localeOptions={available.map((option) => ({
|
||||
value: option.value,
|
||||
flag: option.flag,
|
||||
shortLabel: option.value.toUpperCase(),
|
||||
}))}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<SiteFooter
|
||||
primaryItems={footerContent.primary}
|
||||
secondaryItems={footerContent.secondary}
|
||||
localeLabel={footerContent.localeLabel}
|
||||
rightsLabel={footerContent.rightsLabel}
|
||||
localeOptions={available}
|
||||
currentLocale={current}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export { default as SiteFooter } from './SiteFooter'
|
||||
export { default as SiteNavbar } from './SiteNavbar'
|
||||
export { default as SitePageLayout } from './SitePageLayout'
|
||||
@@ -1,77 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CarplaceApiError, carplaceFetch, carplaceFetchOrDefault, carplacePost } from './api'
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_API_URL
|
||||
delete process.env.API_INTERNAL_URL
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
})
|
||||
|
||||
describe('carplace API helpers', () => {
|
||||
it('unwraps successful GET responses from the data envelope', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
await expect(carplaceFetch('/carplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/carplace\/vehicles$/), {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws rich carplace API errors', async () => {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ message: 'Vehicle unavailable', error: 'VEHICLE_UNAVAILABLE', nextAvailableAt: '2026-07-01T10:00:00.000Z' }),
|
||||
})),
|
||||
})
|
||||
|
||||
await expect(carplaceFetch('/carplace/book')).rejects.toMatchObject({
|
||||
name: 'CarplaceApiError',
|
||||
message: 'Vehicle unavailable',
|
||||
status: 409,
|
||||
code: 'VEHICLE_UNAVAILABLE',
|
||||
nextAvailableAt: '2026-07-01T10:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns fallbacks when GET requests fail', async () => {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })),
|
||||
})
|
||||
|
||||
await expect(carplaceFetchOrDefault('/carplace/homepage', { hero: null })).resolves.toEqual({ hero: null })
|
||||
})
|
||||
|
||||
it('posts JSON payloads and unwraps successful responses', async () => {
|
||||
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com/api/v1'
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
await expect(carplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses a generic error message when the response body is not JSON', async () => {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })),
|
||||
})
|
||||
|
||||
await expect(carplaceFetch('/site/homepage')).rejects.toMatchObject({
|
||||
name: 'CarplaceApiError',
|
||||
message: 'Request failed',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export class CarplaceApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
nextAvailableAt?: string | null
|
||||
|
||||
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
|
||||
super(message)
|
||||
this.name = 'CarplaceApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.nextAvailableAt = nextAvailableAt
|
||||
}
|
||||
}
|
||||
|
||||
export async function carplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
...init,
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new CarplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
export async function carplaceFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await carplaceFetch<T>(path)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function carplacePost<T>(path: string, body: unknown): Promise<T> {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new CarplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
|
||||
return json.data as T
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveBrowserAppUrl, resolveServerAppUrl } from './appUrls'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('carplace app URL resolution', () => {
|
||||
it('leaves server-side browser fallback unchanged when window is unavailable', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('preserves localhost fallbacks but removes trailing slash', () => {
|
||||
installWindow('localhost')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000/')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('rewrites production browser fallbacks to the active host and protocol', () => {
|
||||
installWindow('www.rentaldrivego.ma', 'https:')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000')).toBe('https://www.rentaldrivego.ma')
|
||||
})
|
||||
|
||||
it('keeps path prefixes while rewriting the browser origin', () => {
|
||||
installWindow('rental.example.com', 'https:')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3000/carplace')).toBe('https://rental.example.com/carplace')
|
||||
})
|
||||
|
||||
it('resolves server URLs from forwarded host/proto and handles invalid fallbacks safely', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3000', 'market.example.com', 'https')).toBe('https://market.example.com:3000')
|
||||
expect(resolveServerAppUrl('http://localhost:3000', null)).toBe('http://localhost:3000')
|
||||
expect(resolveServerAppUrl('/relative', 'market.example.com')).toBe('/relative')
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
export function resolveBrowserAppUrl(fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
const h = window.location.hostname
|
||||
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = window.location.protocol
|
||||
target.hostname = h
|
||||
target.port = ''
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
|
||||
if (!host) return fallback
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = `${proto || target.protocol.replace(':', '')}:`
|
||||
target.host = host
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appPrivacyHref,
|
||||
appTermsHref,
|
||||
footerPageHref,
|
||||
footerPageSlugs,
|
||||
getFooterPageContent,
|
||||
isFooterPageSlug,
|
||||
} from './footerContent'
|
||||
import type { CarplaceLanguage } from './i18n'
|
||||
|
||||
const languages: CarplaceLanguage[] = ['en', 'fr', 'ar']
|
||||
|
||||
describe('carplace footer content registry', () => {
|
||||
it('keeps every declared slug routable and recognizable', () => {
|
||||
expect(footerPageSlugs.length).toBeGreaterThan(5)
|
||||
|
||||
for (const slug of footerPageSlugs) {
|
||||
expect(isFooterPageSlug(slug)).toBe(true)
|
||||
expect(footerPageHref[slug]).toBe(`/footer/${slug}`)
|
||||
}
|
||||
|
||||
expect(isFooterPageSlug('privacy')).toBe(false)
|
||||
expect(isFooterPageSlug('../admin')).toBe(false)
|
||||
})
|
||||
|
||||
it('provides localized title and paragraph content for every footer page', () => {
|
||||
for (const language of languages) {
|
||||
for (const slug of footerPageSlugs) {
|
||||
const content = getFooterPageContent(language, slug)
|
||||
|
||||
expect(content.title.trim()).not.toBe('')
|
||||
expect(content.paragraphs.length).toBeGreaterThan(0)
|
||||
expect(content.paragraphs.every((paragraph) => paragraph.trim().length > 0)).toBe(true)
|
||||
|
||||
for (const section of content.sections ?? []) {
|
||||
expect(section.heading.trim()).not.toBe('')
|
||||
expect(section.paragraphs.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps website privacy and application privacy links separate by locale', () => {
|
||||
expect(footerPageHref['privacy-policy']).toBe('/footer/privacy-policy')
|
||||
expect(appPrivacyHref).toEqual({
|
||||
en: '/app-privacy-en',
|
||||
fr: '/app-privacy-fr',
|
||||
ar: '/app-privacy-ar',
|
||||
})
|
||||
expect(appTermsHref).toEqual({
|
||||
en: '/app-tc-en',
|
||||
fr: '/app-tc-fr',
|
||||
ar: '/app-tc-ar',
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes footer policy copy that points users to the locale-matched app policy pages', () => {
|
||||
expect(getFooterPageContent('en', 'privacy-policy').paragraphs[0]).toContain('app-privacy-en')
|
||||
expect(getFooterPageContent('fr', 'privacy-policy').paragraphs[0]).toContain('app-privacy-fr')
|
||||
expect(getFooterPageContent('ar', 'privacy-policy').paragraphs[0]).toContain('app-privacy-ar')
|
||||
|
||||
expect(getFooterPageContent('en', 'general-conditions').paragraphs[0]).toContain('app-tc-en')
|
||||
expect(getFooterPageContent('fr', 'general-conditions').paragraphs[0]).toContain('app-tc-fr')
|
||||
expect(getFooterPageContent('ar', 'general-conditions').paragraphs[0]).toContain('app-tc-ar')
|
||||
})
|
||||
})
|
||||
@@ -1,790 +0,0 @@
|
||||
import type { CarplaceLanguage } from './i18n'
|
||||
|
||||
export const footerPageSlugs = [
|
||||
'about-us',
|
||||
'terms-of-service',
|
||||
'security',
|
||||
'compliance',
|
||||
'privacy-policy',
|
||||
'cookie-policy',
|
||||
'newsletter',
|
||||
'contact-sales',
|
||||
'general-conditions',
|
||||
] as const
|
||||
|
||||
export type FooterPageSlug = (typeof footerPageSlugs)[number]
|
||||
|
||||
export const appPrivacyHref = {
|
||||
en: '/app-privacy-en',
|
||||
fr: '/app-privacy-fr',
|
||||
ar: '/app-privacy-ar',
|
||||
} as const
|
||||
|
||||
export const appTermsHref = {
|
||||
en: '/app-tc-en',
|
||||
fr: '/app-tc-fr',
|
||||
ar: '/app-tc-ar',
|
||||
} as const
|
||||
|
||||
export type FooterPageContent = {
|
||||
title: string
|
||||
paragraphs: string[]
|
||||
sections?: Array<{
|
||||
heading: string
|
||||
paragraphs: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const footerPageHref: Record<FooterPageSlug, string> = {
|
||||
'about-us': '/footer/about-us',
|
||||
'terms-of-service': '/footer/terms-of-service',
|
||||
security: '/footer/security',
|
||||
compliance: '/footer/compliance',
|
||||
'privacy-policy': '/footer/privacy-policy',
|
||||
'cookie-policy': '/footer/cookie-policy',
|
||||
newsletter: '/footer/newsletter',
|
||||
'contact-sales': '/footer/contact-sales',
|
||||
'general-conditions': '/footer/general-conditions',
|
||||
}
|
||||
|
||||
const footerContent: Record<CarplaceLanguage, Record<FooterPageSlug, FooterPageContent>> = {
|
||||
en: {
|
||||
'about-us': {
|
||||
title: 'About Us',
|
||||
paragraphs: [
|
||||
'RentalDriveGo helps car rental businesses streamline operations, manage vehicle fleets, and grow reservations through one powerful platform. Our tools are designed to simplify fleet tracking, booking management, customer reservations, payments, and day-to-day rental operations so companies can focus on delivering exceptional service instead of drowning in spreadsheets and phone calls.',
|
||||
'Whether you operate a small local rental agency or a large multi-location fleet, RentalDriveGo provides the technology to help you scale efficiently and serve customers with confidence.',
|
||||
],
|
||||
},
|
||||
'terms-of-service': {
|
||||
title: 'Terms of Service',
|
||||
paragraphs: [
|
||||
'By using RentalDriveGo, you agree to comply with our platform policies, usage guidelines, and applicable laws. Our services are intended for legitimate car rental businesses and their authorized users. Users are responsible for maintaining account security, providing accurate business information, and using the platform responsibly.',
|
||||
'RentalDriveGo reserves the right to suspend accounts involved in fraudulent activity, misuse, or violations of these terms.',
|
||||
],
|
||||
},
|
||||
security: {
|
||||
title: 'Security',
|
||||
paragraphs: [
|
||||
'RentalDriveGo takes security seriously. We use modern encryption standards, secure cloud infrastructure, and continuous monitoring to help protect your business data, customer information, and reservation records.',
|
||||
'Our platform is built with security best practices to minimize risks and ensure reliable system performance.',
|
||||
],
|
||||
},
|
||||
compliance: {
|
||||
title: 'Compliance',
|
||||
paragraphs: [
|
||||
'RentalDriveGo is committed to operating in accordance with industry standards and applicable privacy and data protection regulations. We continuously review our systems and operational practices to maintain compliance, security, and transparency for our customers and partners.',
|
||||
],
|
||||
},
|
||||
'privacy-policy': {
|
||||
title: 'Privacy Policy',
|
||||
paragraphs: [
|
||||
'You are currently viewing the privacy policy for the RentalDriveGo website. To consult the application privacy policy, please visit: https://www.rentaldrivego.ma/app-privacy-en',
|
||||
'RentalDriveGo respects your privacy and is committed to handling your personal data in a transparent manner. This policy explains how we collect, use, store, and share your personal information when you use our website, and it describes your rights regarding the processing of your personal data.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Data Controller Contact Details',
|
||||
paragraphs: [
|
||||
'This privacy information applies to data processing carried out by RentalDriveGo.',
|
||||
'Telephone: +212 6 05 65 17 51',
|
||||
'Email: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Personal Data We Collect, How We Use It, and How Long We Keep It',
|
||||
paragraphs: [
|
||||
'When you browse our website for information only, without registering for a service or otherwise sending us personal data, your browser automatically sends certain technical information to our hosting provider, such as your browser type, language and version, the date and time of the request, your IP address, pages visited, referring website, volume of data transferred, and operating system.',
|
||||
'This information is temporarily stored to make the website available on your device, support internal analysis, improve the presentation of the website, and detect or prevent misuse. We do not combine this data with other data sources and we do not seek to identify you directly from it. This data is deleted as soon as it is no longer needed for these purposes.',
|
||||
'If you contact us by web form or email, we may collect your name, email address, telephone number, and any other information you choose to provide. We use this information to respond to your questions, contact you when relevant services or registrations become available, or manage participation in campaigns or competitions where applicable.',
|
||||
'We keep your personal data for as long as you maintain an account with us. If you want your personal data to be removed, you must submit a formal request, subject to any legal or contractual retention obligations that may apply.',
|
||||
'You may withdraw your consent at any time where processing is based on consent. Any processing carried out before withdrawal remains lawful.',
|
||||
'We may also use personal data for website review and improvement, market research, analysis, surveys, and marketing activities related to our products and services, subject to applicable law.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Third-Party Service Providers',
|
||||
paragraphs: [
|
||||
'We do not transfer your personal data to third parties unless we are legally required to do so, the transfer is necessary for the performance of a contractual relationship, or you have expressly agreed to it in advance.',
|
||||
'We may work with service providers in Morocco and abroad for technical and website-related services, including hosting and maintenance.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Security',
|
||||
paragraphs: [
|
||||
'The security of your personal data is important to us. We apply appropriate organizational, technical, and physical measures to protect the personal information we collect, both during transmission and after receipt.',
|
||||
'Our security measures and privacy practices are reviewed and improved on an ongoing basis.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Retention of Personal Data',
|
||||
paragraphs: [
|
||||
'We keep personal data only for as long as we are legally entitled to do so, as long as the purpose of processing continues, and provided that no valid objection has been raised.',
|
||||
'Where legal retention periods apply, the data will be stored for the required duration. Once the applicable period has expired, the data will be deleted unless it is still needed for the performance or preparation of a contract.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Transfer to a Foreign Country',
|
||||
paragraphs: [
|
||||
'The arrangements described above may result in your personal data being stored or processed in foreign countries.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Your Rights',
|
||||
paragraphs: [
|
||||
'Under Law No. 09-08 relating to the protection of individuals with regard to the processing of personal data, you may have the right to access your personal data, obtain additional information, request correction of inaccurate data, and object, where applicable, to the processing of your data or to its transfer to third parties.',
|
||||
'To exercise your rights, you may send a request including your full name and a copy of your identity document to: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-policy': {
|
||||
title: 'Cookie Policy',
|
||||
paragraphs: [
|
||||
'We use cookies exclusively to operate the platform and remember your personal preferences. We do not use cookies for advertising, cross-site tracking, or behavioural profiling, and we do not share cookie data with third parties for marketing purposes.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Authentication Cookie',
|
||||
paragraphs: [
|
||||
'Name: employee_session',
|
||||
'This cookie is set when you sign in to the RentalDriveGo workspace. It stores a cryptographically signed token that verifies your identity for the duration of your session. It expires automatically after 8 hours and is deleted when you sign out. This cookie is strictly necessary — without it, access to the dashboard and protected areas is not possible.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Language Preference Cookie',
|
||||
paragraphs: [
|
||||
'Name: rentaldrivego-language',
|
||||
'Stores your chosen display language (English, French, or Arabic) so the interface appears in your preferred language on every visit across all parts of the platform. It is retained for one year.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Theme Preference Cookie',
|
||||
paragraphs: [
|
||||
'Name: rentaldrivego-theme',
|
||||
'Stores your preferred colour scheme (light or dark mode). It is read immediately when a page loads to apply the correct theme before the interface is drawn, preventing a visual flash. It is retained for one year.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Per-User Preference Cookies',
|
||||
paragraphs: [
|
||||
'Names: rentaldrivego-language--[id], rentaldrivego-theme--[id]',
|
||||
'When you are signed in, your language and theme preferences are also saved under a cookie linked to your account identifier. This allows multiple employees sharing a device to each maintain independent preferences. These cookies are retained for one year.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Legacy Preference Cookies',
|
||||
paragraphs: [
|
||||
'Names: dashboard-language, carplace-language',
|
||||
'These cookies exist for backward compatibility with earlier versions of the platform and store the same language preference as the primary cookie. They are read only as a fallback and will be phased out in a future update.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Managing Your Cookies',
|
||||
paragraphs: [
|
||||
'You can manage or delete cookies at any time through your browser settings. Blocking the authentication cookie will prevent you from signing in. Blocking preference cookies will cause your language and theme settings to reset on each visit. For general information about cookies, you may visit http://www.allaboutcookies.org/.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
newsletter: {
|
||||
title: 'Newsletter',
|
||||
paragraphs: [
|
||||
'Stay updated with the latest industry insights, product updates, feature releases, and fleet management tips from RentalDriveGo.',
|
||||
'Subscribe to our newsletter and receive helpful resources designed to help car rental businesses grow smarter and operate more efficiently.',
|
||||
],
|
||||
},
|
||||
'contact-sales': {
|
||||
title: 'Contact Sales',
|
||||
paragraphs: [
|
||||
'Want to see how RentalDriveGo can transform your rental operations? Our sales team is ready to help you explore the right solution for your business.',
|
||||
'Contact us to schedule a demo, discuss pricing, or learn more about our fleet management and reservation platform.',
|
||||
],
|
||||
},
|
||||
'general-conditions': {
|
||||
title: 'General Conditions of Use',
|
||||
paragraphs: [
|
||||
'You are currently viewing the General Conditions of Use for the RentalDriveGo website. To consult the General Conditions of Use of the application, please visit: https://www.rentaldrivego.ma/app-tc-en',
|
||||
'These General Conditions of Use define the rules that apply when you visit, access, browse, contact us through, or otherwise interact with the website www.rentaldrivego.ma.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Purpose',
|
||||
paragraphs: [
|
||||
'These conditions govern the use of the website www.rentaldrivego.ma, operated by RentalDriveGo, a limited liability company registered in the Azrou Trade Register under number 00000, with its registered office in Azrou',
|
||||
'They also define the standards relating to the content of the website and apply to any person who visits the website, contacts us or other users through it, links to it, or interacts with it in any way.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Website Offer',
|
||||
paragraphs: [
|
||||
'We operate the website solely for information, prospecting, and market research purposes.',
|
||||
'Information relating to the RentalDriveGo loyalty program may be presented to visitors, and download links may be made available through the website.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Contact',
|
||||
paragraphs: [
|
||||
'You may contact us through the website or by email at service-client@rentaldrivego.ma.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Acceptance of the General Conditions of Use',
|
||||
paragraphs: [
|
||||
'By using the website, you accept these General Conditions of Use and agree to comply with them. If you do not accept them, you must not use the website.',
|
||||
'Our Privacy Policy and Cookie Policy also apply to your use of the website.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Changes to the General Conditions of Use',
|
||||
paragraphs: [
|
||||
'We may amend these General Conditions of Use at any time. Each time you use the website, you should review the current version.',
|
||||
'We also reserve the right to amend, correct, or update any information published on the website at any time.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Changes to Website Content and Availability',
|
||||
paragraphs: [
|
||||
'We may update or modify the content of the website at any time to reflect changes in the needs of users and partners.',
|
||||
'The website is made available free of charge. While we use reasonable means to prevent and resolve interruptions, we cannot guarantee continuous or uninterrupted availability.',
|
||||
'We may suspend, withdraw, or restrict the availability of all or part of the website for commercial or operational reasons.',
|
||||
'You are responsible for ensuring that all persons accessing the website through your internet connection are aware of these conditions and comply with them.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Scope of Information on the Website',
|
||||
paragraphs: [
|
||||
'The content of the website is provided for general information purposes only and does not constitute a recommendation on which you should rely.',
|
||||
'Although we make reasonable efforts to update the information on the website, we do not guarantee, whether expressly or implicitly, that the content is accurate, complete, or current.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '8. Third-Party Websites',
|
||||
paragraphs: [
|
||||
'We are not responsible for third-party websites to which the website may link.',
|
||||
'Any hyperlinks to third-party content, websites, or resources are provided for information purposes only, and we cannot be held responsible for their content, accuracy, or quality.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '9. Limitation of Liability',
|
||||
paragraphs: [
|
||||
'You agree that access to and use of the website and any associated pages is at your own risk.',
|
||||
'Nothing in these conditions excludes or limits our liability where such exclusion or limitation would be unlawful.',
|
||||
'Neither we nor our partners or affiliates may be held liable for damage resulting from the use of information, files, links, or websites accessible from or received through the website.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '10. Your Responsibilities',
|
||||
paragraphs: [
|
||||
'You acknowledge that we may use the information you provide to us where you have given the required consent or where another lawful basis applies.',
|
||||
'You agree to use the website only for lawful purposes.',
|
||||
'If we consider that a breach of these General Conditions of Use has occurred, we may take any action we deem appropriate.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '11. Intellectual Property',
|
||||
paragraphs: [
|
||||
'The website is our property and the entire website is protected by intellectual property laws.',
|
||||
'All rights of reproduction and representation are reserved.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
'about-us': {
|
||||
title: 'À propos de nous',
|
||||
paragraphs: [
|
||||
'RentalDriveGo aide les entreprises de location de voitures à simplifier leurs opérations, gérer leurs flottes et augmenter leurs réservations grâce à une plateforme complète. Nos outils permettent de gérer les véhicules, les réservations, les paiements et les opérations quotidiennes de manière efficace et centralisée.',
|
||||
'Que vous soyez une petite agence locale ou une grande entreprise multi-sites, RentalDriveGo vous aide à développer votre activité avec des solutions modernes et fiables.',
|
||||
],
|
||||
},
|
||||
'terms-of-service': {
|
||||
title: 'Conditions d’utilisation',
|
||||
paragraphs: [
|
||||
'En utilisant RentalDriveGo, vous acceptez de respecter nos politiques, nos règles d’utilisation et les lois applicables. Nos services sont destinés aux entreprises de location de voitures et à leurs utilisateurs autorisés.',
|
||||
'RentalDriveGo se réserve le droit de suspendre tout compte impliqué dans une activité frauduleuse ou une violation des présentes conditions.',
|
||||
],
|
||||
},
|
||||
security: {
|
||||
title: 'Sécurité',
|
||||
paragraphs: [
|
||||
'RentalDriveGo applique des standards de sécurité modernes afin de protéger les données de votre entreprise, les informations de vos clients et vos réservations.',
|
||||
'Notre plateforme utilise le chiffrement, une infrastructure cloud sécurisée et une surveillance continue afin d’assurer la confidentialité et la fiabilité des services.',
|
||||
],
|
||||
},
|
||||
compliance: {
|
||||
title: 'Conformité',
|
||||
paragraphs: [
|
||||
'RentalDriveGo s’engage à respecter les réglementations applicables ainsi que les standards du secteur concernant la protection des données et la sécurité.',
|
||||
'Nous améliorons continuellement nos systèmes afin de garantir transparence, conformité et confiance à nos clients.',
|
||||
],
|
||||
},
|
||||
'privacy-policy': {
|
||||
title: 'Politique de confidentialité',
|
||||
paragraphs: [
|
||||
'Vous consultez actuellement la politique de confidentialité du site RentalDriveGo. Pour consulter la politique de confidentialité de l’application, veuillez visiter : https://www.rentaldrivego.ma/app-privacy-fr',
|
||||
'RentalDriveGo respecte votre vie privée et s’engage à traiter vos données personnelles de manière transparente. Cette politique explique comment nous collectons, utilisons, conservons et partageons vos informations personnelles lorsque vous utilisez notre site internet, ainsi que les droits dont vous disposez concernant ce traitement.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Coordonnées du responsable du traitement',
|
||||
paragraphs: [
|
||||
'Les présentes informations sur la protection des données s’appliquent aux traitements effectués par RentalDriveGo.',
|
||||
'Téléphone : +212 6 05 65 17 51',
|
||||
'Email : service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Données personnelles collectées, finalités d’utilisation et durée de conservation',
|
||||
paragraphs: [
|
||||
'Lorsque vous consultez notre site à des fins d’information uniquement, sans vous inscrire à un service et sans nous transmettre de données personnelles par un autre moyen, votre navigateur envoie automatiquement certaines informations techniques à notre hébergeur, notamment le type, la langue et la version du navigateur, la date et l’heure de la demande, l’adresse IP, les pages consultées, le site d’origine, le volume de données transférées et le système d’exploitation.',
|
||||
'Ces informations sont temporairement conservées afin de permettre l’affichage du site sur votre appareil, de réaliser des analyses internes, d’améliorer la présentation du site et de détecter ou prévenir les abus. Nous ne croisons pas ces données avec d’autres sources et nous ne cherchons pas à vous identifier directement à partir de celles-ci. Elles sont supprimées dès qu’elles ne sont plus nécessaires à ces finalités.',
|
||||
'Si vous nous contactez via un formulaire ou par email, nous pouvons collecter votre nom, votre adresse email, votre numéro de téléphone et toute autre information que vous choisissez de nous communiquer. Nous utilisons ces données pour répondre à vos questions, vous contacter lorsque certains services ou inscriptions deviennent disponibles, ou gérer votre participation à des campagnes ou concours le cas échéant.',
|
||||
'Nous conservons vos données personnelles aussi longtemps que vous détenez un compte chez nous. Si vous souhaitez la suppression de vos données personnelles, vous devez nous adresser une demande formelle, sous réserve de toute obligation légale ou contractuelle de conservation applicable.',
|
||||
'Vous pouvez retirer votre consentement à tout moment lorsque le traitement repose sur celui-ci. Les traitements effectués avant ce retrait demeurent licites.',
|
||||
'Nous pouvons également utiliser les données personnelles pour l’évaluation et l’amélioration du site, les études de marché, les analyses, les enquêtes ainsi que les actions de marketing liées à nos produits et services, dans le respect de la loi applicable.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Prestataires tiers',
|
||||
paragraphs: [
|
||||
'Nous ne communiquons pas vos données personnelles à des tiers sauf en cas d’obligation légale, lorsque ce transfert est nécessaire à l’exécution d’une relation contractuelle, ou lorsque vous y avez expressément consenti au préalable.',
|
||||
'Nous pouvons travailler avec des prestataires situés au Maroc et à l’étranger pour des services techniques et liés au site internet, notamment l’hébergement et la maintenance.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Sécurité',
|
||||
paragraphs: [
|
||||
'La sécurité de vos données personnelles est importante pour nous. Nous mettons en place des mesures organisationnelles, techniques et physiques appropriées afin de protéger les informations personnelles collectées, aussi bien pendant leur transmission qu’après leur réception.',
|
||||
'Nos mesures de sécurité et nos pratiques de confidentialité sont revues et améliorées de manière continue.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Conservation des données personnelles',
|
||||
paragraphs: [
|
||||
'Nous conservons les données personnelles uniquement aussi longtemps que la loi nous y autorise, tant que la finalité du traitement subsiste et en l’absence d’opposition valable.',
|
||||
'Lorsque des délais légaux de conservation s’appliquent, les données sont conservées pendant la durée requise. À l’expiration de cette période, elles sont supprimées sauf si elles restent nécessaires à l’exécution ou à la préparation d’un contrat.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Transfert vers un pays étranger',
|
||||
paragraphs: [
|
||||
'Les traitements décrits ci-dessus peuvent entraîner le stockage ou le traitement de vos données personnelles dans des pays étrangers.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Vos droits',
|
||||
paragraphs: [
|
||||
'Conformément à la loi n° 09-08 relative à la protection des personnes physiques à l’égard du traitement des données à caractère personnel, vous pouvez disposer d’un droit d’accès à vos données, d’un droit d’obtenir des informations complémentaires, d’un droit de rectification des données inexactes et, le cas échéant, d’un droit d’opposition au traitement ou au transfert de vos données à des tiers.',
|
||||
'Pour exercer vos droits, vous pouvez envoyer une demande indiquant votre nom complet et accompagnée d’une copie de votre pièce d’identité à : service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-policy': {
|
||||
title: 'Politique relative aux cookies',
|
||||
paragraphs: [
|
||||
'Nous utilisons des cookies exclusivement pour assurer le fonctionnement de la plateforme et mémoriser vos préférences personnelles. Nous n’utilisons pas de cookies à des fins publicitaires, de suivi intersites ou de profilage comportemental, et nous ne partageons pas les données des cookies avec des tiers à des fins marketing.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Cookie d’authentification',
|
||||
paragraphs: [
|
||||
'Nom : employee_session',
|
||||
'Ce cookie est défini lorsque vous vous connectez à l’espace de travail RentalDriveGo. Il stocke un jeton cryptographiquement signé qui vérifie votre identité pour la durée de votre session. Il expire automatiquement après 8 heures et est supprimé lors de la déconnexion. Ce cookie est strictement nécessaire — sans lui, l’accès au tableau de bord et aux espaces protégés n’est pas possible.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Cookie de préférence de langue',
|
||||
paragraphs: [
|
||||
'Nom : rentaldrivego-language',
|
||||
'Enregistre la langue d’affichage choisie (français, anglais ou arabe) afin que l’interface s’affiche dans votre langue préférée à chaque visite sur l’ensemble de la plateforme. Il est conservé pendant un an.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Cookie de préférence de thème',
|
||||
paragraphs: [
|
||||
'Nom : rentaldrivego-theme',
|
||||
'Enregistre le schéma de couleurs préféré (mode clair ou sombre). Il est lu dès le chargement de la page afin d’appliquer le bon thème avant l’affichage de l’interface, évitant ainsi un changement visuel brusque. Il est conservé pendant un an.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Cookies de préférence par utilisateur',
|
||||
paragraphs: [
|
||||
'Noms : rentaldrivego-language--[id], rentaldrivego-theme--[id]',
|
||||
'Lorsque vous êtes connecté, vos préférences de langue et de thème sont également enregistrées dans un cookie lié à votre identifiant de compte. Cela permet à plusieurs employés partageant un appareil de conserver des préférences indépendantes. Ces cookies sont conservés pendant un an.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Cookies de préférence hérités',
|
||||
paragraphs: [
|
||||
'Noms : dashboard-language, carplace-language',
|
||||
'Ces cookies existent pour assurer la compatibilité ascendante avec les versions antérieures de la plateforme et stockent la même préférence de langue que le cookie principal. Ils ne sont lus qu’en secours et seront supprimés dans une prochaine mise à jour.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Gestion de vos cookies',
|
||||
paragraphs: [
|
||||
'Vous pouvez gérer ou supprimer les cookies à tout moment via les paramètres de votre navigateur. Bloquer le cookie d’authentification vous empêchera de vous connecter. Bloquer les cookies de préférence entraînera la réinitialisation de vos paramètres de langue et de thème à chaque visite. Pour des informations générales sur les cookies, vous pouvez consulter http://www.allaboutcookies.org/.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
newsletter: {
|
||||
title: 'Newsletter',
|
||||
paragraphs: [
|
||||
'Recevez les dernières actualités, mises à jour produits, conseils de gestion de flotte et nouveautés de RentalDriveGo.',
|
||||
'Abonnez-vous à notre newsletter pour rester informé et développer votre activité plus efficacement.',
|
||||
],
|
||||
},
|
||||
'contact-sales': {
|
||||
title: 'Contacter les ventes',
|
||||
paragraphs: [
|
||||
'Vous souhaitez découvrir comment RentalDriveGo peut améliorer vos opérations de location de voitures ?',
|
||||
'Notre équipe commerciale est disponible pour organiser une démonstration, répondre à vos questions et vous proposer la solution adaptée à votre entreprise.',
|
||||
],
|
||||
},
|
||||
'general-conditions': {
|
||||
title: 'Conditions générales d’utilisation',
|
||||
paragraphs: [
|
||||
'Vous consultez actuellement les Conditions générales d’utilisation du site RentalDriveGo. Pour consulter les Conditions générales d’utilisation de l’application, veuillez visiter : https://www.rentaldrivego.ma/app-tc-fr',
|
||||
'Les présentes Conditions générales d’utilisation définissent les règles applicables lorsque vous visitez, accédez, parcourez, contactez ou utilisez de toute autre manière le site www.rentaldrivego.ma.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. Objet',
|
||||
paragraphs: [
|
||||
'Les présentes conditions régissent l’utilisation du site www.rentaldrivego.ma exploité par RentalDriveGo, société à responsabilité limitée immatriculée au registre du commerce d’Azrou sous le numéro 00000, dont le siège social est situé à Azrou.',
|
||||
'Elles définissent également les règles relatives au contenu du site et s’appliquent à toute personne qui visite le site, nous contacte ou contacte d’autres utilisateurs par son intermédiaire, y crée un lien ou interagit avec lui de quelque manière que ce soit.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. Offre du site',
|
||||
paragraphs: [
|
||||
'Nous exploitons le site exclusivement à des fins d’information, de prospection et d’étude de marché.',
|
||||
'Des informations relatives au programme de fidélité RentalDriveGo peuvent être portées à la connaissance des visiteurs et des liens de téléchargement peuvent être mis à leur disposition.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. Contact',
|
||||
paragraphs: [
|
||||
'Vous pouvez nous contacter via le site ou par email à l’adresse service-client@rentaldrivego.ma.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. Acceptation des Conditions générales d’utilisation',
|
||||
paragraphs: [
|
||||
'En utilisant le site, vous acceptez les présentes Conditions générales d’utilisation et vous vous engagez à les respecter. Si vous ne les acceptez pas, vous ne devez pas utiliser le site.',
|
||||
'Notre Politique de confidentialité et notre Politique relative aux cookies s’appliquent également à votre utilisation du site.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. Modification des Conditions générales d’utilisation',
|
||||
paragraphs: [
|
||||
'Nous pouvons modifier à tout moment les présentes Conditions générales d’utilisation. À chaque utilisation du site, nous vous invitons à consulter la version en vigueur.',
|
||||
'Nous nous réservons également le droit de modifier, corriger ou mettre à jour à tout moment les informations publiées sur le site.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. Modification du contenu et disponibilité du site',
|
||||
paragraphs: [
|
||||
'Nous pouvons mettre à jour ou modifier le contenu du site à tout moment afin de refléter l’évolution des besoins de nos utilisateurs et partenaires.',
|
||||
'Le site est mis à votre disposition gratuitement. Bien que nous mettions en œuvre des moyens raisonnables pour prévenir et résoudre les interruptions, nous ne pouvons garantir une disponibilité constante et ininterrompue.',
|
||||
'Nous pouvons suspendre, retirer ou restreindre l’accès à tout ou partie du site pour des raisons commerciales ou opérationnelles.',
|
||||
'Il vous appartient de veiller à ce que toute personne accédant au site par votre connexion internet ait connaissance des présentes conditions et les respecte.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. Portée des informations figurant sur le site',
|
||||
paragraphs: [
|
||||
'Le contenu du site est fourni à titre d’information générale uniquement et ne constitue ni un conseil ni une recommandation sur lesquels vous devriez vous appuyer.',
|
||||
'Bien que nous fassions des efforts raisonnables pour mettre à jour les informations publiées sur le site, nous ne garantissons pas, expressément ou implicitement, qu’elles soient exactes, complètes ou à jour.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '8. Responsabilité à l’égard des sites tiers',
|
||||
paragraphs: [
|
||||
'Nous ne sommes pas responsables des sites internet tiers vers lesquels le site peut contenir des liens.',
|
||||
'Les liens hypertextes vers des contenus, sites ou ressources de tiers sont fournis uniquement à titre informatif, et nous ne pouvons être tenus responsables de leur contenu, de leur exactitude ou de leur qualité.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '9. Limitation de responsabilité',
|
||||
paragraphs: [
|
||||
'Vous reconnaissez que l’accès et l’utilisation du site ainsi que des pages qui y sont rattachées se font à vos propres risques.',
|
||||
'Aucune disposition des présentes conditions n’exclut ou ne limite notre responsabilité lorsqu’une telle exclusion ou limitation serait contraire à la loi.',
|
||||
'Ni nous, ni nos partenaires ou affiliés, ne pourrons être tenus responsables des dommages résultant de l’utilisation des informations, fichiers, liens ou sites accessibles depuis le site ou reçus par son intermédiaire.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '10. Vos responsabilités',
|
||||
paragraphs: [
|
||||
'Vous reconnaissez que nous pouvons utiliser les informations que vous nous fournissez lorsque vous avez donné le consentement requis ou lorsqu’une autre base légale s’applique.',
|
||||
'Vous vous engagez à utiliser le site uniquement à des fins licites.',
|
||||
'Si nous estimons qu’une violation des présentes Conditions générales d’utilisation a eu lieu, nous pouvons prendre toute mesure que nous jugeons appropriée.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '11. Propriété intellectuelle',
|
||||
paragraphs: [
|
||||
'Le site est notre propriété et l’ensemble du site est protégé par la législation relative à la propriété intellectuelle.',
|
||||
'Tous les droits de reproduction et de représentation sont réservés.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
ar: {
|
||||
'about-us': {
|
||||
title: 'من نحن',
|
||||
paragraphs: [
|
||||
'تساعد منصة RentalDriveGo شركات تأجير السيارات على إدارة عملياتها بسهولة، وتنظيم أساطيلها، وزيادة الحجوزات من خلال منصة متكاملة وحديثة. توفر أدواتنا حلولاً لإدارة المركبات والحجوزات والمدفوعات والعمليات اليومية بكفاءة عالية.',
|
||||
'سواء كنت تدير شركة محلية صغيرة أو أسطولاً كبيراً متعدد الفروع، فإن RentalDriveGo توفر لك التكنولوجيا التي تساعدك على النمو وإدارة أعمالك بثقة.',
|
||||
],
|
||||
},
|
||||
'terms-of-service': {
|
||||
title: 'شروط الاستخدام',
|
||||
paragraphs: [
|
||||
'باستخدامك لمنصة RentalDriveGo، فإنك توافق على الالتزام بسياسات المنصة والقوانين المعمول بها. خدماتنا مخصصة لشركات تأجير السيارات والمستخدمين المصرح لهم فقط.',
|
||||
'تحتفظ RentalDriveGo بحق تعليق أي حساب يشارك في أنشطة احتيالية أو يخالف شروط الاستخدام.',
|
||||
],
|
||||
},
|
||||
security: {
|
||||
title: 'الأمان',
|
||||
paragraphs: [
|
||||
'تلتزم RentalDriveGo بتوفير أعلى معايير الأمان لحماية بيانات شركتك ومعلومات عملائك وسجلات الحجوزات.',
|
||||
'تستخدم منصتنا تقنيات تشفير حديثة وبنية سحابية آمنة وأنظمة مراقبة مستمرة لضمان حماية البيانات واستقرار الخدمة.',
|
||||
],
|
||||
},
|
||||
compliance: {
|
||||
title: 'الامتثال',
|
||||
paragraphs: [
|
||||
'تلتزم RentalDriveGo بالامتثال للمعايير واللوائح المعمول بها المتعلقة بحماية البيانات والخصوصية والأمان.',
|
||||
'نعمل باستمرار على تطوير أنظمتنا وإجراءاتنا لضمان الشفافية والثقة لعملائنا وشركائنا.',
|
||||
],
|
||||
},
|
||||
'privacy-policy': {
|
||||
title: 'سياسة الخصوصية',
|
||||
paragraphs: [
|
||||
'أنت الآن تطّلع على سياسة الخصوصية الخاصة بموقع RentalDriveGo. للاطلاع على سياسة الخصوصية الخاصة بالتطبيق، يرجى زيارة: https://www.rentaldrivego.ma/app-privacy-ar',
|
||||
'تحترم RentalDriveGo خصوصيتك وتلتزم بالتعامل مع بياناتك الشخصية بشفافية. توضح هذه السياسة كيفية جمع معلوماتك الشخصية واستخدامها وتخزينها ومشاركتها عند استخدامك لموقعنا، كما تشرح حقوقك المتعلقة بمعالجة بياناتك الشخصية.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. بيانات الاتصال الخاصة بمسؤول المعالجة',
|
||||
paragraphs: [
|
||||
'تنطبق هذه المعلومات المتعلقة بحماية البيانات على المعالجة التي تقوم بها RentalDriveGo.',
|
||||
'الهاتف: +212 6 05 65 17 51',
|
||||
'البريد الإلكتروني: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. ما هي البيانات الشخصية التي نجمعها، وكيف نستخدمها، ومدة الاحتفاظ بها',
|
||||
paragraphs: [
|
||||
'عند زيارتك لموقعنا لأغراض الاطلاع فقط، من دون التسجيل في أي خدمة أو تزويدنا ببيانات شخصية بأي وسيلة أخرى، يرسل متصفحك تلقائياً بعض المعلومات التقنية إلى مزود الاستضافة، مثل نوع المتصفح ولغته وإصداره، وتاريخ ووقت الطلب، وعنوان IP، والصفحات التي تمت زيارتها، والموقع المحيل، وحجم البيانات المنقولة، ونظام التشغيل.',
|
||||
'يتم الاحتفاظ بهذه المعلومات مؤقتاً لتمكين عرض الموقع على جهازك، ودعم التحليل الداخلي، وتحسين طريقة عرض الموقع، واكتشاف أو منع أي إساءة استخدام. نحن لا ندمج هذه البيانات مع مصادر أخرى ولا نسعى إلى التعرف المباشر على هويتك من خلالها. ويتم حذفها بمجرد انتفاء الحاجة إليها لهذه الأغراض.',
|
||||
'إذا تواصلت معنا عبر نموذج إلكتروني أو عبر البريد الإلكتروني، فقد نجمع اسمك وبريدك الإلكتروني ورقم هاتفك وأي معلومات أخرى تختار تقديمها لنا. نستخدم هذه البيانات للرد على استفساراتك، أو التواصل معك عندما تصبح بعض الخدمات أو التسجيلات متاحة، أو لإدارة مشاركتك في الحملات أو المسابقات عند الاقتضاء.',
|
||||
'نحتفظ ببياناتك الشخصية طوال الفترة التي يكون لديك فيها حساب لدينا. وإذا كنت ترغب في حذف بياناتك الشخصية، فيجب عليك تقديم طلب رسمي بذلك، مع مراعاة أي التزامات قانونية أو تعاقدية قد تفرض الاحتفاظ ببعض البيانات.',
|
||||
'يمكنك سحب موافقتك في أي وقت عندما تكون المعالجة قائمة على الموافقة. وتظل المعالجة التي تمت قبل سحب الموافقة مشروعة.',
|
||||
'وقد نستخدم أيضاً البيانات الشخصية لمراجعة الموقع وتحسينه، وإجراء أبحاث السوق، والتحليلات، والاستبيانات، والأنشطة التسويقية المتعلقة بمنتجاتنا وخدماتنا، وفقاً للقانون المعمول به.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. مزودو الخدمات من الأطراف الثالثة',
|
||||
paragraphs: [
|
||||
'لا نقوم بنقل بياناتك الشخصية إلى أطراف ثالثة إلا إذا كان ذلك مطلوباً بموجب القانون، أو كان ضرورياً لتنفيذ علاقة تعاقدية، أو إذا كنت قد وافقت على ذلك صراحة مسبقاً.',
|
||||
'قد نتعاون مع مزودي خدمات في المغرب وخارجه لتقديم الخدمات التقنية والخدمات المرتبطة بالموقع، بما في ذلك الاستضافة والصيانة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. الأمان',
|
||||
paragraphs: [
|
||||
'تمثل حماية بياناتك الشخصية أولوية بالنسبة لنا. لذلك نعتمد تدابير تنظيمية وتقنية ومادية مناسبة لحماية المعلومات الشخصية التي نجمعها، سواء أثناء نقلها أو بعد استلامها.',
|
||||
'نقوم بمراجعة وتحسين إجراءات الأمان وممارسات الخصوصية لدينا بشكل مستمر.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. الاحتفاظ بالبيانات الشخصية',
|
||||
paragraphs: [
|
||||
'نحتفظ بالبيانات الشخصية فقط للمدة التي يسمح بها القانون، وطالما أن الغرض من المعالجة لا يزال قائماً، وما لم يتم تقديم اعتراض مشروع.',
|
||||
'وعندما تكون هناك مدد قانونية للاحتفاظ بالبيانات، فإننا نلتزم بها. وبعد انتهاء المدة المعمول بها، يتم حذف البيانات ما لم تعد هناك حاجة إليها لتنفيذ عقد أو لاتخاذ خطوات تمهيدية لإبرام عقد.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. النقل إلى دولة أجنبية',
|
||||
paragraphs: [
|
||||
'قد تؤدي الترتيبات المذكورة أعلاه إلى تخزين بياناتك الشخصية أو معالجتها في دول أجنبية.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. حقوقك',
|
||||
paragraphs: [
|
||||
'وفقاً للقانون رقم 09-08 المتعلق بحماية الأشخاص تجاه معالجة المعطيات ذات الطابع الشخصي، قد يكون لك الحق في الوصول إلى بياناتك، والحصول على معلومات إضافية، وطلب تصحيح البيانات غير الدقيقة، والاعتراض عند الاقتضاء على معالجة بياناتك أو على نقلها إلى أطراف ثالثة.',
|
||||
'لممارسة حقوقك، يمكنك إرسال طلب يتضمن اسمك الكامل ونسخة من وثيقة هويتك إلى: service-client@rentaldrivego.ma',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-policy': {
|
||||
title: 'سياسة ملفات تعريف الارتباط',
|
||||
paragraphs: [
|
||||
'نستخدم ملفات تعريف الارتباط حصرياً لتشغيل المنصة وتذكّر تفضيلاتك الشخصية. لا نستخدم ملفات تعريف الارتباط لأغراض إعلانية أو تتبع المستخدمين عبر المواقع أو بناء ملفات تعريف سلوكية، ولا نشارك بيانات ملفات تعريف الارتباط مع أطراف ثالثة لأغراض تسويقية.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. ملف تعريف الارتباط للمصادقة',
|
||||
paragraphs: [
|
||||
'الاسم: employee_session',
|
||||
'يُعيَّن هذا الملف عند تسجيل دخولك إلى مساحة عمل RentalDriveGo. يخزّن رمزاً موقّعاً تشفيرياً يُثبت هويتك طوال مدة الجلسة. تنتهي صلاحيته تلقائياً بعد 8 ساعات ويُحذف عند تسجيل الخروج. هذا الملف ضروري بشكل مطلق — إذ لا يمكن الوصول إلى لوحة التحكم والمناطق المحمية بدونه.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. ملف تعريف الارتباط لتفضيل اللغة',
|
||||
paragraphs: [
|
||||
'الاسم: rentaldrivego-language',
|
||||
'يحفظ لغة العرض التي اخترتها (العربية أو الفرنسية أو الإنجليزية) حتى تظهر الواجهة بلغتك المفضّلة في كل زيارة وعبر جميع أجزاء المنصة. يُحتفظ به لمدة سنة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. ملف تعريف الارتباط لتفضيل المظهر',
|
||||
paragraphs: [
|
||||
'الاسم: rentaldrivego-theme',
|
||||
'يحفظ نظام الألوان المفضّل لديك (الوضع الفاتح أو الداكن). يُقرأ فور تحميل الصفحة لتطبيق المظهر الصحيح قبل عرض الواجهة، ما يمنع حدوث وميض بصري غير مرغوب. يُحتفظ به لمدة سنة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. ملفات تعريف الارتباط للتفضيلات الخاصة بكل مستخدم',
|
||||
paragraphs: [
|
||||
'الأسماء: rentaldrivego-language--[id], rentaldrivego-theme--[id]',
|
||||
'عند تسجيل دخولك، تُحفظ تفضيلات اللغة والمظهر الخاصة بك أيضاً في ملف مرتبط بمعرّف حسابك. يتيح ذلك لأكثر من موظف يتشاركون جهازاً واحداً الاحتفاظ بتفضيلات مستقلة. يُحتفظ بهذه الملفات لمدة سنة.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. ملفات تعريف الارتباط الموروثة',
|
||||
paragraphs: [
|
||||
'الأسماء: dashboard-language, carplace-language',
|
||||
'توجد هذه الملفات لضمان التوافق مع الإصدارات السابقة من المنصة، وتخزّن تفضيل اللغة ذاته الموجود في الملف الرئيسي. لا تُقرأ إلا كخيار احتياطي، وستُزال في تحديث مستقبلي.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. إدارة ملفات تعريف الارتباط',
|
||||
paragraphs: [
|
||||
'يمكنك إدارة ملفات تعريف الارتباط أو حذفها في أي وقت من خلال إعدادات متصفحك. سيمنعك حظر ملف المصادقة من تسجيل الدخول. أما حظر ملفات التفضيلات فسيؤدي إلى إعادة ضبط إعدادات اللغة والمظهر في كل زيارة. للاطلاع على معلومات عامة حول ملفات تعريف الارتباط، يمكنك زيارة http://www.allaboutcookies.org/.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
newsletter: {
|
||||
title: 'النشرة الإخبارية',
|
||||
paragraphs: [
|
||||
'ابقَ على اطلاع بآخر تحديثات المنصة وأخبار القطاع ونصائح إدارة الأساطيل من RentalDriveGo.',
|
||||
'اشترك في نشرتنا الإخبارية للحصول على محتوى مفيد يساعدك على تطوير أعمالك بكفاءة أكبر.',
|
||||
],
|
||||
},
|
||||
'contact-sales': {
|
||||
title: 'تواصل مع المبيعات',
|
||||
paragraphs: [
|
||||
'هل ترغب في معرفة كيف يمكن لـ RentalDriveGo تطوير عمليات شركتك؟',
|
||||
'فريق المبيعات لدينا جاهز لتقديم عرض توضيحي والإجابة على استفساراتك ومساعدتك في اختيار الحل المناسب لاحتياجاتك.',
|
||||
],
|
||||
},
|
||||
'general-conditions': {
|
||||
title: 'الشروط العامة لاستخدام الموقع',
|
||||
paragraphs: [
|
||||
'أنت الآن تطّلع على الشروط العامة لاستخدام موقع RentalDriveGo. للاطلاع على الشروط العامة لاستخدام التطبيق، يرجى زيارة: https://www.rentaldrivego.ma/app-tc-ar',
|
||||
'تحدد هذه الشروط العامة القواعد المطبقة عند زيارتك أو دخولك أو تصفحك أو تواصلك أو تفاعلك بأي شكل آخر مع الموقع www.rentaldrivego.ma.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: '1. الغرض',
|
||||
paragraphs: [
|
||||
'تنظم هذه الشروط استخدام الموقع www.rentaldrivego.ma الذي تديره شركة RentalDriveGo، وهي شركة ذات مسؤولية محدودة مسجلة في السجل التجاري بأزرو تحت رقم 00000، ويقع مقرها الاجتماعي في أزرو.',
|
||||
'كما تحدد هذه الشروط القواعد المتعلقة بمحتوى الموقع، وتطبق على كل شخص يزور الموقع أو يتواصل معنا أو مع مستخدمين آخرين من خلاله أو يضع رابطاً إليه أو يتفاعل معه بأي طريقة كانت.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '2. ما يتيحه الموقع',
|
||||
paragraphs: [
|
||||
'نقوم بتشغيل الموقع حصرياً لأغراض الإعلام والاستكشاف ودراسة السوق.',
|
||||
'قد يتم عرض معلومات تتعلق ببرنامج الولاء الخاص بـ RentalDriveGo للزوار، كما قد تُتاح لهم روابط للتنزيل عبر الموقع.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '3. الاتصال',
|
||||
paragraphs: [
|
||||
'يمكنك التواصل معنا عبر الموقع أو عبر البريد الإلكتروني على العنوان service-client@rentaldrivego.ma.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '4. قبول الشروط العامة للاستخدام',
|
||||
paragraphs: [
|
||||
'باستخدامك للموقع، فإنك توافق على هذه الشروط العامة للاستخدام وتتعهد بالالتزام بها. وإذا كنت لا تقبلها، فيجب عليك عدم استخدام الموقع.',
|
||||
'كما تنطبق سياسة الخصوصية وسياسة ملفات تعريف الارتباط الخاصة بنا على استخدامك للموقع.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '5. تعديل الشروط العامة للاستخدام',
|
||||
paragraphs: [
|
||||
'يجوز لنا تعديل هذه الشروط العامة للاستخدام في أي وقت. ولذلك ننصحك بالاطلاع على النسخة السارية كلما رغبت في استخدام الموقع.',
|
||||
'كما نحتفظ بالحق في تعديل أو تصحيح أو تحديث أي معلومات منشورة على الموقع في أي وقت.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '6. تعديل محتوى الموقع وتوفّره',
|
||||
paragraphs: [
|
||||
'يجوز لنا تحديث أو تعديل محتوى الموقع في أي وقت بما يعكس تطور احتياجات المستخدمين والشركاء.',
|
||||
'يتم توفير الموقع مجاناً. وعلى الرغم من أننا نبذل وسائل معقولة لمنع الانقطاعات وحلها، فإننا لا نضمن التوفر المستمر أو غير المنقطع للموقع.',
|
||||
'يجوز لنا تعليق أو سحب أو تقييد إتاحة كل الموقع أو جزء منه لأسباب تجارية أو تشغيلية.',
|
||||
'أنت مسؤول عن التأكد من أن جميع الأشخاص الذين يدخلون إلى الموقع عبر اتصالك بالإنترنت على علم بهذه الشروط ويلتزمون بها.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '7. نطاق المعلومات الواردة في الموقع',
|
||||
paragraphs: [
|
||||
'يتم تقديم محتوى الموقع لأغراض إعلامية عامة فقط، ولا يشكل خدمة توصية أو مشورة يمكن الاعتماد عليها.',
|
||||
'ورغم أننا نبذل جهوداً معقولة لتحديث المعلومات المنشورة على الموقع، فإننا لا نضمن، صراحةً أو ضمناً، أن يكون المحتوى دقيقاً أو كاملاً أو محدثاً.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '8. المسؤولية تجاه المواقع التابعة للغير',
|
||||
paragraphs: [
|
||||
'لا نتحمل المسؤولية عن المواقع الإلكترونية التابعة للغير التي قد يتضمن الموقع روابط إليها.',
|
||||
'أي روابط نحو محتوى أو مواقع أو موارد تابعة للغير يتم توفيرها لأغراض إعلامية فقط، ولا يمكن تحميلنا المسؤولية عن محتواها أو دقتها أو جودتها.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '9. تحديد المسؤولية',
|
||||
paragraphs: [
|
||||
'أنت توافق على أن الدخول إلى الموقع واستخدامه، وكذلك الصفحات المرتبطة به، يكون على مسؤوليتك الخاصة.',
|
||||
'لا تستبعد هذه الشروط ولا تحد من مسؤوليتنا في الحالات التي يكون فيها هذا الاستبعاد أو التحديد غير قانوني.',
|
||||
'ولا نحن ولا شركاؤنا أو الجهات المرتبطة بنا نتحمل أي مسؤولية عن الأضرار الناتجة عن استخدام المعلومات أو الملفات أو الروابط أو المواقع التي يمكن الوصول إليها من خلال الموقع أو استلامها عبره.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '10. مسؤولياتك',
|
||||
paragraphs: [
|
||||
'أنت تقر بأننا قد نستخدم المعلومات التي تقدمها لنا عندما تكون قد منحت الموافقة المطلوبة أو عندما يوجد أساس قانوني آخر لذلك.',
|
||||
'وتتعهد باستخدام الموقع فقط لأغراض مشروعة.',
|
||||
'وإذا رأينا أن هناك خرقاً لهذه الشروط العامة للاستخدام، فيجوز لنا اتخاذ أي إجراء نراه مناسباً.',
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: '11. الملكية الفكرية',
|
||||
paragraphs: [
|
||||
'الموقع ملك لنا، والموقع بكامله محمي بموجب القوانين المتعلقة بالملكية الفكرية.',
|
||||
'وجميع حقوق النسخ والتمثيل محفوظة.',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function isFooterPageSlug(value: string): value is FooterPageSlug {
|
||||
return footerPageSlugs.includes(value as FooterPageSlug)
|
||||
}
|
||||
|
||||
export function getFooterPageContent(language: CarplaceLanguage, slug: FooterPageSlug): FooterPageContent {
|
||||
return footerContent[language][slug]
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const cookieValues = vi.hoisted(() => new Map<string, string>())
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieValues.get(name)
|
||||
return value === undefined ? undefined : { value }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { getCarplaceLanguage } from './i18n.server'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE } from './i18n'
|
||||
|
||||
describe('getCarplaceLanguage', () => {
|
||||
beforeEach(() => {
|
||||
cookieValues.clear()
|
||||
})
|
||||
|
||||
it('prefers the shared language cookie over the legacy carplace cookie', async () => {
|
||||
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'ar')
|
||||
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
|
||||
|
||||
await expect(getCarplaceLanguage()).resolves.toBe('ar')
|
||||
})
|
||||
|
||||
it('falls back to the legacy carplace cookie during migration', async () => {
|
||||
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
|
||||
|
||||
await expect(getCarplaceLanguage()).resolves.toBe('fr')
|
||||
})
|
||||
|
||||
it('defaults to English when cookies are absent or invalid', async () => {
|
||||
await expect(getCarplaceLanguage()).resolves.toBe('en')
|
||||
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
|
||||
|
||||
await expect(getCarplaceLanguage()).resolves.toBe('en')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isCarplaceLanguage, type CarplaceLanguage } from './i18n'
|
||||
|
||||
export async function getCarplaceLanguage(): Promise<CarplaceLanguage> {
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue =
|
||||
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
|
||||
cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
|
||||
return isCarplaceLanguage(cookieValue) ? cookieValue : 'en'
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isCarplaceLanguage } from './i18n'
|
||||
|
||||
describe('carplace language guard', () => {
|
||||
it('accepts the supported carplace locales', () => {
|
||||
expect(isCarplaceLanguage('en')).toBe(true)
|
||||
expect(isCarplaceLanguage('fr')).toBe(true)
|
||||
expect(isCarplaceLanguage('ar')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unsupported, missing, and case-mismatched locales', () => {
|
||||
expect(isCarplaceLanguage('de')).toBe(false)
|
||||
expect(isCarplaceLanguage('EN')).toBe(false)
|
||||
expect(isCarplaceLanguage('')).toBe(false)
|
||||
expect(isCarplaceLanguage(null)).toBe(false)
|
||||
expect(isCarplaceLanguage(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
export type CarplaceLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
export const MARKETPLACE_LANGUAGE_COOKIE = 'carplace-language'
|
||||
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
|
||||
export function isCarplaceLanguage(value: string | null | undefined): value is CarplaceLanguage {
|
||||
return value === 'en' || value === 'fr' || value === 'ar'
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getScopedPreferenceCookieName, getScopedPreferenceKey, readScopedPreference, writeScopedPreference } from './preferences'
|
||||
|
||||
function installBrowser(cookie = '') {
|
||||
const store = new Map<string, string>()
|
||||
let cookieValue = cookie
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get cookie() { return cookieValue },
|
||||
set cookie(value: string) {
|
||||
const [pair] = value.split(';')
|
||||
const [name, val] = pair.split('=')
|
||||
cookieValue = cookieValue
|
||||
.split(';')
|
||||
.map((chunk) => chunk.trim())
|
||||
.filter(Boolean)
|
||||
.filter((chunk) => !chunk.startsWith(`${name}=`))
|
||||
.concat(`${name}=${val}`)
|
||||
.join('; ')
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'atob', {
|
||||
configurable: true,
|
||||
value: (value: string) => Buffer.from(value, 'base64').toString('binary'),
|
||||
})
|
||||
return { store, get cookie() { return cookieValue } }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
Reflect.deleteProperty(globalThis, 'atob')
|
||||
})
|
||||
|
||||
describe('carplace scoped preferences', () => {
|
||||
it('returns unscoped keys when no usable token exists', () => {
|
||||
installBrowser()
|
||||
expect(getScopedPreferenceKey('rentaldrivego-language')).toBe('rentaldrivego-language')
|
||||
expect(getScopedPreferenceCookieName('rentaldrivego-language')).toBe('rentaldrivego-language')
|
||||
})
|
||||
|
||||
it('reads shared cookies before legacy local-storage fallbacks', () => {
|
||||
const browser = installBrowser('rentaldrivego-language=fr')
|
||||
browser.store.set('carplace-language', 'ar')
|
||||
|
||||
expect(readScopedPreference('rentaldrivego-language', ['carplace-language'])).toBe('fr')
|
||||
})
|
||||
|
||||
it('writes shared and legacy values without auth-token scoping', () => {
|
||||
const browser = installBrowser()
|
||||
|
||||
writeScopedPreference('rentaldrivego-theme', 'dark', ['dashboard-theme'])
|
||||
|
||||
expect(browser.store.get('rentaldrivego-theme')).toBe('dark')
|
||||
expect(browser.store.get('dashboard-theme')).toBe('dark')
|
||||
expect(browser.cookie).toContain('rentaldrivego-theme=dark')
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
|
||||
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
|
||||
function readEmployeeToken() {
|
||||
return null
|
||||
}
|
||||
|
||||
function decodeEmployeeId(token: string | null) {
|
||||
if (!token) return null
|
||||
|
||||
try {
|
||||
const encoded = token.split('.')[1] ?? ''
|
||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const payload = JSON.parse(atob(padded)) as { sub?: string }
|
||||
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getScopedPreferenceKey(baseKey: string) {
|
||||
const employeeId = decodeEmployeeId(readEmployeeToken())
|
||||
return employeeId ? `${baseKey}:${employeeId}` : baseKey
|
||||
}
|
||||
|
||||
export function getScopedPreferenceCookieName(baseKey: string) {
|
||||
const employeeId = decodeEmployeeId(readEmployeeToken())
|
||||
return employeeId ? `${baseKey}--${employeeId}` : baseKey
|
||||
}
|
||||
|
||||
function readCookie(name: string) {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.map((chunk) => chunk.trim())
|
||||
.find((chunk) => chunk.startsWith(`${name}=`))
|
||||
?.slice(name.length + 1) ?? null
|
||||
}
|
||||
|
||||
function writeCookie(name: string, value: string) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
|
||||
export function readCurrentUserScopedPreference(baseKey: string) {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
|
||||
if (scopedCookie) return scopedCookie
|
||||
|
||||
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
|
||||
}
|
||||
|
||||
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
|
||||
if (scopedCookie) return scopedCookie
|
||||
|
||||
const sharedCookie = readCookie(baseKey)
|
||||
if (sharedCookie) return sharedCookie
|
||||
|
||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
||||
const candidates = [scopedKey, baseKey, ...legacyKeys]
|
||||
|
||||
for (const key of candidates) {
|
||||
const value = window.localStorage.getItem(key)
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
||||
const scopedCookie = getScopedPreferenceCookieName(baseKey)
|
||||
|
||||
writeCookie(baseKey, value)
|
||||
if (scopedCookie !== baseKey) {
|
||||
writeCookie(scopedCookie, value)
|
||||
}
|
||||
|
||||
window.localStorage.setItem(scopedKey, value)
|
||||
window.localStorage.setItem(baseKey, value)
|
||||
|
||||
for (const key of legacyKeys) {
|
||||
window.localStorage.setItem(key, value)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installBrowser(_token?: string) {
|
||||
const store = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => store.delete(key)),
|
||||
},
|
||||
},
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('renter client helpers', () => {
|
||||
it('clears renter sessions through the API logout endpoint', async () => {
|
||||
installBrowser('renter-token')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { success: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
const { clearRenterSession } = await import('./renter')
|
||||
|
||||
clearRenterSession()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/auth\/renter\/logout$/), expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}))
|
||||
})
|
||||
|
||||
it('sends cookies and unwraps renter profile data', async () => {
|
||||
installBrowser('renter-token')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: { id: 'renter_1' } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
const { loadRenterProfile } = await import('./renter')
|
||||
|
||||
await expect(loadRenterProfile()).resolves.toEqual({ id: 'renter_1' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/auth\/renter\/me$/), expect.objectContaining({
|
||||
credentials: 'include',
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('removes stale tokens and throws renter auth errors on 401', async () => {
|
||||
installBrowser('expired-token')
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ ok: false, status: 401, json: async () => ({ message: 'Unauthorized' }) })),
|
||||
})
|
||||
const { loadRenterNotifications, RenterAuthError } = await import('./renter')
|
||||
|
||||
await expect(loadRenterNotifications()).rejects.toBeInstanceOf(RenterAuthError)
|
||||
|
||||
})
|
||||
|
||||
it('serializes preference updates to the renter notification endpoint', async () => {
|
||||
installBrowser('renter-token')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { success: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
const { updateRenterPreferences } = await import('./renter')
|
||||
const body = [{ notificationType: 'reservation', channel: 'email', enabled: true }]
|
||||
|
||||
await expect(updateRenterPreferences(body)).resolves.toEqual({ success: true })
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/notifications\/renter\/preferences$/), expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export interface SavedCompany {
|
||||
id: string
|
||||
brand?: {
|
||||
displayName: string
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface RenterProfile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
preferredLocale?: string
|
||||
preferredCurrency?: string
|
||||
emailVerified?: boolean
|
||||
savedCompanies?: SavedCompany[]
|
||||
}
|
||||
|
||||
export interface RenterPreference {
|
||||
notificationType: string
|
||||
channel: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RenterNotification {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
type: string
|
||||
status: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export class RenterAuthError extends Error {
|
||||
constructor(message = 'Authentication required') {
|
||||
super(message)
|
||||
this.name = 'RenterAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
async function renterFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
const json = await res.json().catch(() => null)
|
||||
if (res.status === 401) {
|
||||
throw new RenterAuthError()
|
||||
}
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
export function clearRenterSession() {
|
||||
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
|
||||
}
|
||||
|
||||
export function loadRenterProfile() {
|
||||
return renterFetch<RenterProfile>('/auth/renter/me')
|
||||
}
|
||||
|
||||
export function updateRenterProfile(body: Partial<RenterProfile>) {
|
||||
return renterFetch<RenterProfile>('/auth/renter/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function loadRenterNotifications() {
|
||||
return renterFetch<RenterNotification[]>('/notifications/renter')
|
||||
}
|
||||
|
||||
export function markRenterNotificationRead(id: string) {
|
||||
return renterFetch<{ success: boolean }>(`/notifications/renter/${id}/read`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function markAllRenterNotificationsRead() {
|
||||
return renterFetch<{ success: boolean }>('/notifications/renter/read-all', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function loadRenterPreferences() {
|
||||
return renterFetch<RenterPreference[]>('/notifications/renter/preferences')
|
||||
}
|
||||
|
||||
export function updateRenterPreferences(body: RenterPreference[]) {
|
||||
return renterFetch<{ success: boolean }>('/notifications/renter/preferences', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const nextServer = vi.hoisted(() => {
|
||||
const next = vi.fn((init?: { request?: { headers?: Headers } }) => ({
|
||||
kind: 'next',
|
||||
init,
|
||||
cookies: {
|
||||
set: vi.fn(),
|
||||
},
|
||||
}))
|
||||
const redirect = vi.fn((url: URL, status?: number) => ({
|
||||
kind: 'redirect',
|
||||
url: url.toString(),
|
||||
status,
|
||||
}))
|
||||
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
|
||||
kind: 'response',
|
||||
body,
|
||||
status: init?.status,
|
||||
})) as any
|
||||
MockNextResponse.next = next
|
||||
MockNextResponse.redirect = redirect
|
||||
return { next, redirect, MockNextResponse }
|
||||
})
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: nextServer.MockNextResponse,
|
||||
}))
|
||||
|
||||
function request(options: { cookies?: Record<string, string>; headers?: Record<string, string> } = {}) {
|
||||
const cookies = new Map(Object.entries(options.cookies ?? {}))
|
||||
const headers = new Headers(options.headers ?? {})
|
||||
return {
|
||||
nextUrl: cloneableUrl('http://localhost:3000/'),
|
||||
cookies: {
|
||||
get: vi.fn((name: string) => {
|
||||
const value = cookies.get(name)
|
||||
return value ? { value } : undefined
|
||||
}),
|
||||
},
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
function cloneableUrl(input: string): URL {
|
||||
const url = new URL(input)
|
||||
;(url as URL & { clone: () => URL }).clone = () => cloneableUrl(url.toString())
|
||||
return url
|
||||
}
|
||||
|
||||
function middlewareRequest(input: string) {
|
||||
return {
|
||||
cookies: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
headers: new Headers(),
|
||||
nextUrl: cloneableUrl(input),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nextServer.next.mockClear()
|
||||
nextServer.redirect.mockClear()
|
||||
})
|
||||
|
||||
describe('carplace proxy language bootstrap', () => {
|
||||
|
||||
it('rejects proxy subrequest headers before language handling', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(request({
|
||||
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
|
||||
}) as never) as any
|
||||
|
||||
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
|
||||
expect(nextServer.next).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does nothing when the canonical shared language cookie is valid', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(request({ cookies: { 'rentaldrivego-language': 'fr' } }) as never) as any
|
||||
|
||||
expect(response.kind).toBe('next')
|
||||
expect(nextServer.next).toHaveBeenCalledWith()
|
||||
expect(response.cookies.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('migrates the legacy carplace language cookie into the shared cookie and request headers', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(request({
|
||||
cookies: { 'carplace-language': 'ar' },
|
||||
headers: { cookie: 'session=abc' },
|
||||
}) as never) as any
|
||||
|
||||
expect(response.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'ar', expect.objectContaining({
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
}))
|
||||
expect(response.init?.request?.headers?.get('cookie')).toBe('session=abc; rentaldrivego-language=ar')
|
||||
})
|
||||
|
||||
it('detects Arabic and French from Accept-Language before falling back to English', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const arabic = proxy(request({ headers: { 'accept-language': 'ar-MA,fr;q=0.8,en;q=0.6' } }) as never) as any
|
||||
expect(arabic.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'ar', expect.any(Object))
|
||||
expect(arabic.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=ar')
|
||||
|
||||
const french = proxy(request({ headers: { 'accept-language': 'de-DE,fr-FR;q=0.7,en;q=0.3' } }) as never) as any
|
||||
expect(french.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'fr', expect.any(Object))
|
||||
|
||||
const fallback = proxy(request({ headers: { 'accept-language': 'de-DE,es;q=0.9' } }) as never) as any
|
||||
expect(fallback.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'en', expect.any(Object))
|
||||
})
|
||||
|
||||
it('rejects invalid shared and legacy language values before resolving from headers', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(request({
|
||||
cookies: { 'rentaldrivego-language': 'es', 'carplace-language': 'it' },
|
||||
headers: { 'accept-language': 'fr-CA,ar;q=0.5' },
|
||||
}) as never) as any
|
||||
|
||||
expect(response.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'fr', expect.any(Object))
|
||||
expect(response.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=fr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('carplace middleware dashboard path canonicalization', () => {
|
||||
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'http://localhost:3000/dashboard', status: 307 })
|
||||
})
|
||||
|
||||
it('preserves nested dashboard paths while removing the duplicate prefix', async () => {
|
||||
const { proxy } = await import('./proxy')
|
||||
|
||||
const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard/fleet?tab=active') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'http://localhost:3000/dashboard/fleet?tab=active', status: 307 })
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
const MARKETPLACE_LANGUAGE_COOKIE = 'carplace-language'
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
|
||||
if (!request.headers.has('x-middleware-subrequest')) return null
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
function isValidLanguage(val: string | null | undefined): val is 'en' | 'fr' | 'ar' {
|
||||
return val === 'en' || val === 'fr' || val === 'ar'
|
||||
}
|
||||
|
||||
function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
|
||||
if (!header) return 'en'
|
||||
for (const entry of header.split(',')) {
|
||||
const code = entry.split(';')[0].trim().split('-')[0].toLowerCase()
|
||||
if (code === 'ar' || code === 'fr' || code === 'en') return code as 'en' | 'fr' | 'ar'
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
function deduplicateDashboardPath(pathname: string): string | null {
|
||||
if (!pathname.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) return null
|
||||
|
||||
let normalized = pathname
|
||||
while (normalized.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) {
|
||||
normalized = normalized.slice(DASHBOARD_BASE_PATH.length)
|
||||
}
|
||||
return normalized || DASHBOARD_BASE_PATH
|
||||
}
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const rejected = rejectInternalSubrequest(request)
|
||||
if (rejected) return rejected
|
||||
|
||||
const deduped = deduplicateDashboardPath(request.nextUrl.pathname)
|
||||
if (deduped) {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = deduped
|
||||
return NextResponse.redirect(url, 307)
|
||||
}
|
||||
|
||||
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
|
||||
|
||||
if (isValidLanguage(sharedLang)) return NextResponse.next()
|
||||
|
||||
const legacyLang = request.cookies.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
|
||||
const resolved: 'en' | 'fr' | 'ar' = isValidLanguage(legacyLang)
|
||||
? legacyLang
|
||||
: detectFromAcceptLanguage(request.headers.get('accept-language'))
|
||||
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
const existing = request.headers.get('cookie') ?? ''
|
||||
const injected = `${SHARED_LANGUAGE_COOKIE}=${resolved}`
|
||||
requestHeaders.set('cookie', existing ? `${existing}; ${injected}` : injected)
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
||||
|
||||
response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, {
|
||||
maxAge: 365 * 24 * 60 * 60,
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/dashboard/dashboard/:path*',
|
||||
'/((?!_next/static|_next/image|_next/webpack-hmr|favicon\\.ico|.*\\.(?:png|ico|jpg|jpeg|svg|webp)$).*)',
|
||||
],
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/* RentalDriveGo Phase 16 application design tokens.
|
||||
* Derived from the approved marketing-site token system and adapted for the
|
||||
* class-based theme controls used by the operational applications.
|
||||
*/
|
||||
:root {
|
||||
--rdg-blue-50: #eff6ff;
|
||||
--rdg-blue-100: #dbeafe;
|
||||
--rdg-blue-200: #bfdbfe;
|
||||
--rdg-blue-500: #2874e8;
|
||||
--rdg-blue-600: #1b5dd8;
|
||||
--rdg-blue-700: #174bb5;
|
||||
--rdg-blue-800: #183d88;
|
||||
--rdg-orange-100: #ffedd5;
|
||||
--rdg-orange-400: #fb923c;
|
||||
--rdg-orange-600: #ea580c;
|
||||
--rdg-orange-700: #c2410c;
|
||||
--rdg-navy-950: #081426;
|
||||
--rdg-navy-900: #0d1b2e;
|
||||
--rdg-navy-850: #11233b;
|
||||
--rdg-navy-800: #162b46;
|
||||
--rdg-gray-25: #fbfdff;
|
||||
--rdg-gray-50: #f7f9fc;
|
||||
--rdg-gray-100: #eef2f7;
|
||||
--rdg-gray-200: #dbe3ed;
|
||||
--rdg-gray-300: #c6d0dc;
|
||||
--rdg-gray-500: #64748b;
|
||||
--rdg-gray-600: #475569;
|
||||
--rdg-gray-700: #334155;
|
||||
--rdg-gray-800: #1e293b;
|
||||
--rdg-gray-900: #0f172a;
|
||||
--rdg-green-100: #dcfce7;
|
||||
--rdg-green-700: #15803d;
|
||||
--rdg-amber-100: #fef3c7;
|
||||
--rdg-amber-800: #92400e;
|
||||
--rdg-red-100: #fee2e2;
|
||||
--rdg-red-700: #b91c1c;
|
||||
|
||||
--rdg-radius-sm: 10px;
|
||||
--rdg-radius-md: 16px;
|
||||
--rdg-radius-lg: 24px;
|
||||
--rdg-radius-pill: 999px;
|
||||
--rdg-touch-min: 44px;
|
||||
--rdg-container-max: 1280px;
|
||||
--rdg-font-latin: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--rdg-font-arabic: "Noto Sans Arabic", Tahoma, Arial, sans-serif;
|
||||
|
||||
--rdg-surface-page: var(--rdg-gray-25);
|
||||
--rdg-surface-primary: #ffffff;
|
||||
--rdg-surface-elevated: #ffffff;
|
||||
--rdg-surface-muted: #f2f6fb;
|
||||
--rdg-surface-strong: #e8f0fa;
|
||||
--rdg-text-primary: #102035;
|
||||
--rdg-text-secondary: #52647a;
|
||||
--rdg-text-subdued: var(--rdg-gray-500);
|
||||
--rdg-text-inverse: #f8fafc;
|
||||
--rdg-border: #d8e2ee;
|
||||
--rdg-border-strong: #bdcad9;
|
||||
--rdg-action-primary: var(--rdg-blue-700);
|
||||
--rdg-action-primary-hover: var(--rdg-blue-800);
|
||||
--rdg-action-conversion: var(--rdg-orange-700);
|
||||
--rdg-action-conversion-hover: #9a3412;
|
||||
--rdg-focus: #f97316;
|
||||
--rdg-soft-blue: #eef5ff;
|
||||
--rdg-soft-orange: #fff7ed;
|
||||
--rdg-shadow-color: rgba(8, 20, 38, 0.12);
|
||||
--rdg-shadow-subtle: 0 1px 2px var(--rdg-shadow-color);
|
||||
--rdg-shadow-card: 0 20px 60px var(--rdg-shadow-color);
|
||||
--rdg-shadow-overlay: 0 28px 80px var(--rdg-shadow-color);
|
||||
|
||||
/* Compatibility aliases for the existing apps. */
|
||||
--primary: var(--rdg-action-primary);
|
||||
--primary-hover: var(--rdg-action-primary-hover);
|
||||
--accent: var(--rdg-action-conversion);
|
||||
--bg-elevated: var(--rdg-surface-elevated);
|
||||
--border-accent: var(--rdg-border);
|
||||
--glass-bg: color-mix(in srgb, var(--rdg-surface-elevated) 88%, transparent);
|
||||
--shadow-card: var(--rdg-shadow-card);
|
||||
--shadow-glow: 0 0 20px color-mix(in srgb, var(--rdg-action-conversion) 18%, transparent);
|
||||
}
|
||||
|
||||
html.dark,
|
||||
html[data-theme='dark'] {
|
||||
--rdg-surface-page: var(--rdg-navy-950);
|
||||
--rdg-surface-primary: var(--rdg-navy-900);
|
||||
--rdg-surface-elevated: var(--rdg-navy-850);
|
||||
--rdg-surface-muted: #0b1a2d;
|
||||
--rdg-surface-strong: #172d48;
|
||||
--rdg-text-primary: #eef5ff;
|
||||
--rdg-text-secondary: #adbed2;
|
||||
--rdg-text-subdued: var(--rdg-gray-300);
|
||||
--rdg-text-inverse: var(--rdg-navy-950);
|
||||
--rdg-border: #263c56;
|
||||
--rdg-border-strong: #3a536e;
|
||||
--rdg-action-primary: #76a9ff;
|
||||
--rdg-action-primary-hover: #9bc2ff;
|
||||
--rdg-action-conversion: var(--rdg-orange-400);
|
||||
--rdg-action-conversion-hover: #fdba74;
|
||||
--rdg-focus: var(--rdg-orange-400);
|
||||
--rdg-soft-blue: #10284a;
|
||||
--rdg-soft-orange: #3a2015;
|
||||
--rdg-shadow-color: rgba(0, 0, 0, 0.36);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.light,
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html[lang='ar'] body {
|
||||
font-family: var(--rdg-font-arabic);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--rdg-blue-200);
|
||||
color: var(--rdg-gray-900);
|
||||
}
|
||||
|
||||
html.dark ::selection,
|
||||
html[data-theme='dark'] ::selection {
|
||||
background: var(--rdg-blue-800);
|
||||
color: var(--rdg-gray-25);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--rdg-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user