archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
@@ -0,0 +1,245 @@
'use client'
import { useEffect, useState } from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import {
cloneMarketplaceHomepageContent,
resolveMarketplaceHomepageSections,
type MarketplaceHomepageConfig,
type MarketplaceHomepageSectionType,
} from '@rentaldrivego/types'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
export default function HomeContent() {
const { language } = useMarketplacePreferences()
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
const dict = content[language]
const sections = resolveMarketplaceHomepageSections(dict.sections)
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY&currency=MAD`
useEffect(() => {
let cancelled = false
async function loadHomepageContent() {
try {
const res = await fetch(`${API_BASE}/site/platform/homepage`, { cache: 'no-store' })
const json = await res.json().catch(() => null)
if (!res.ok || !json?.data || cancelled) return
setContent(json.data as MarketplaceHomepageConfig)
} catch {
// Fall back to the shared defaults when the API is unavailable.
}
}
loadHomepageContent()
return () => {
cancelled = true
}
}, [])
function hasSection(section: MarketplaceHomepageSectionType) {
return sections.includes(section)
}
return (
<main className="min-h-screen overflow-hidden bg-transparent">
<section className="relative">
<div className="site-glow absolute inset-x-0 top-0 h-[38rem]" />
<div className="shell relative py-10 sm:py-14 lg:py-20">
<div className={`grid gap-8 ${hasSection('hero') && hasSection('surface') ? 'lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]' : ''}`}>
{hasSection('hero') ? (
<div className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/70">
<p className="text-xs font-bold uppercase tracking-[0.32em] text-orange-700 dark:text-orange-300">{dict.heroKicker}</p>
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-blue-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
{dict.heroTitle}
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
{dict.heroBody}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<a
href={starterSignupHref}
className="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"
>
{dict.startTrial}
</a>
<a
href="/explore"
className="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-stone-200 dark:hover:border-stone-200 dark:hover:text-white"
>
{dict.exploreVehicles}
</a>
</div>
<div className="mt-10 grid gap-3 sm:grid-cols-3">
{dict.metrics.map(({ value, label }) => (
<div key={value} className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 p-4 dark:border-blue-900 dark:bg-blue-950/50">
<p className="text-xs font-bold tracking-[0.28em] text-stone-400 dark:text-stone-500">{value}</p>
<p className="mt-3 text-sm font-semibold leading-6 text-stone-900 dark:text-stone-100">{label}</p>
</div>
))}
</div>
</div>
) : null}
{hasSection('surface') ? (
<div className="relative overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] p-6 text-white shadow-[0_30px_80px_rgba(6,19,46,0.40)] dark:border-blue-800">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(249,115,22,0.30),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(96,165,250,0.22),transparent_32%)]" />
<div className="relative">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
{dict.surfaceLabel}
</span>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
{dict.liveLabel}
</span>
</div>
<h2 className="mt-6 text-3xl font-black leading-tight tracking-[-0.03em]">{dict.surfaceTitle}</h2>
<p className="mt-4 text-sm leading-7 text-stone-300">{dict.surfaceBody}</p>
<div className="mt-8 space-y-3">
{[
['01', dict.trustedFleets],
['02', dict.brandedFlows],
['03', dict.multiTenant],
].map(([step, label]) => (
<div key={step} className="flex items-center justify-between rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-xs font-bold text-blue-950">
{step}
</span>
<p className="text-sm font-semibold text-white">{label}</p>
</div>
<span className="text-lg text-orange-300">+</span>
</div>
))}
</div>
</div>
</div>
) : null}
</div>
{hasSection('audiences') ? <div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<article className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 backdrop-blur dark:border-blue-900 dark:bg-blue-950/60">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.companyKicker}</p>
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
<p className="mt-4 max-w-3xl text-sm leading-7 text-stone-600 dark:text-stone-300">{dict.companyBody}</p>
</article>
<article className="rounded-[2rem] border border-stone-200/80 bg-[#f7efe2] p-7 dark:border-blue-900 dark:bg-[#1d1712]">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.renterKicker}</p>
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.renterTitle}</h2>
<p className="mt-4 text-sm leading-7 text-stone-700 dark:text-stone-300">{dict.renterBody}</p>
</article>
</div> : null}
</div>
</section>
{hasSection('pillars') ? <section className="shell pb-10">
<div className="grid gap-4 lg:grid-cols-3">
{dict.pillars.map(({ title, body }, index) => (
<article
key={title}
className={`rounded-[2rem] border p-7 shadow-sm ${
index === 1
? 'border-orange-700 bg-orange-600 text-white dark:border-orange-500 dark:bg-orange-500 dark:text-white'
: 'border-stone-200 bg-white dark:border-blue-900 dark:bg-blue-950'
}`}
>
<p
className={`text-xs font-bold uppercase tracking-[0.26em] ${
index === 1 ? 'text-stone-300 dark:text-stone-700' : 'text-stone-400 dark:text-stone-500'
}`}
>
0{index + 1}
</p>
<h3 className="mt-4 text-2xl font-black tracking-[-0.03em]">{title}</h3>
<p
className={`mt-4 text-sm leading-7 ${
index === 1 ? 'text-stone-200 dark:text-stone-800' : 'text-stone-600 dark:text-stone-300'
}`}
>
{body}
</p>
</article>
))}
</div>
</section> : null}
{(hasSection('features') || hasSection('steps')) ? <section className="shell py-10">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
{hasSection('features') ? (
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#f5f8ff_0%,#edf2ff_100%)] p-8 dark:border-blue-900 dark:bg-[linear-gradient(160deg,#0c1830_0%,#10203e_100%)]">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.featureLabel}</p>
<div className="mt-6 space-y-3">
{dict.features.map((item, index) => (
<div key={item} className="flex items-start gap-4 rounded-[1.25rem] border border-stone-200/80 bg-white/85 px-4 py-4 dark:border-blue-800 dark:bg-blue-950/40">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white dark:bg-orange-500 dark:text-white">
{index + 1}
</span>
<p className="text-sm font-semibold leading-6 text-stone-800 dark:text-stone-100">{item}</p>
</div>
))}
</div>
</div>
) : <div />}
{hasSection('steps') ? (
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-blue-900 dark:bg-blue-950">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.readyKicker}</p>
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">{dict.stepsTitle}</h2>
</div>
</div>
<div className="mt-8 space-y-4">
{dict.steps.map(({ step, title, body }) => (
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
{dict.stepLabel} {step}
</p>
<h3 className="mt-3 text-lg font-black text-blue-950 dark:text-stone-50">{title}</h3>
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
</article>
))}
</div>
</div>
) : <div />}
</div>
</section> : null}
{hasSection('closing') ? <section className="shell pb-16 pt-6 sm:pb-20">
<div className="overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] px-8 py-10 text-white dark:border-blue-800">
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-300">{dict.readyKicker}</p>
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight tracking-[-0.04em] sm:text-5xl">{dict.readyTitle}</h2>
<p className="mt-5 max-w-2xl text-sm leading-8 text-stone-300 sm:text-base">{dict.readyBody}</p>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
<a
href="/pricing"
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-blue-900"
>
{dict.viewPricing}
</a>
<a
href={starterSignupHref}
className="rounded-full bg-orange-300 px-6 py-3 text-sm font-semibold text-blue-950 transition hover:bg-orange-200"
>
{dict.createWorkspace}
</a>
</div>
</div>
</div>
</section> : null}
</main>
)
}
@@ -0,0 +1,31 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import FooterContentPage from '@/components/FooterContentPage'
import AppPrivacyEnPage from './app-privacy-en/page'
import AppPrivacyFrPage from './app-privacy-fr/page'
import AppPrivacyArPage from './app-privacy-ar/page'
import AppTermsEnPage from './app-tc-en/page'
import AppTermsFrPage from './app-tc-fr/page'
import AppTermsArPage from './app-tc-ar/page'
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
expect(isValidElement(element)).toBe(true)
const page = element as React.ReactElement
expect(page.type).toBe(FooterContentPage)
expect(page.props.slug).toBe(slug)
expect(page.props.forcedLanguage).toBe(forcedLanguage)
}
describe('marketplace static app policy pages', () => {
it('binds app privacy pages to explicit locales', () => {
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
})
it('binds app terms pages to explicit locales and the app terms content slug', () => {
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
})
})
@@ -0,0 +1,5 @@
import FooterContentPage from '@/components/FooterContentPage'
export default function AppPrivacyArPage() {
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
}
@@ -0,0 +1,5 @@
import FooterContentPage from '@/components/FooterContentPage'
export default function AppPrivacyEnPage() {
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
}
@@ -0,0 +1,5 @@
import FooterContentPage from '@/components/FooterContentPage'
export default function AppPrivacyFrPage() {
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
}
@@ -0,0 +1,5 @@
import FooterContentPage from '@/components/FooterContentPage'
export default function AppTermsArPage() {
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
}
@@ -0,0 +1,5 @@
import FooterContentPage from '@/components/FooterContentPage'
export default function AppTermsEnPage() {
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
}
@@ -0,0 +1,5 @@
import FooterContentPage from '@/components/FooterContentPage'
export default function AppTermsFrPage() {
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function CompanyWorkspacePage() {
redirect('/')
}
@@ -0,0 +1,295 @@
'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>
)
}
@@ -0,0 +1,112 @@
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>
)
}
@@ -0,0 +1,159 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } 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 getMarketplaceLanguage()
const dict = {
en: {
unavailable: 'Marketplace unavailable',
unavailableTitle: 'Company details are temporarily unavailable.',
unavailableBody: 'The marketplace API is running, but the database is not reachable in this local environment.',
backToExplore: 'Back to explore',
partner: 'Marketplace 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: 'Marketplace indisponible',
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
backToExplore: "Retour à l'exploration",
partner: 'Partenaire marketplace',
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 marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${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>
)
}
@@ -0,0 +1,283 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } 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 getMarketplaceLanguage()
const dict = {
en: {
unavailable: 'Marketplace unavailable',
unavailableTitle: 'Vehicle details are temporarily unavailable.',
unavailableBody: 'The marketplace 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: 'Marketplace indisponible',
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
backToFleet: 'Retour à la flotte',
backToExplore: 'Retour à lexploration',
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 marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${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-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 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>
)
}
@@ -0,0 +1,328 @@
import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } 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?: Record<string, string | string[] | undefined> }) {
const language = await getMarketplaceLanguage()
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 marketplace offers',
company: 'Company',
validUntil: 'Valid until',
offersUnavailable: 'Marketplace 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',
marketplacePartners: 'Marketplace partners',
marketplacePartner: 'Marketplace 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 marketplace mises en avant',
company: 'Entreprise',
validUntil: "Valable jusqu'au",
offersUnavailable: 'Les offres marketplace 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',
marketplacePartners: 'Partenaires marketplace',
marketplacePartner: 'Partenaire marketplace',
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: 'تصفح حسب الشركة',
marketplacePartners: 'شركاء السوق',
marketplacePartner: 'شريك في السوق',
rating: 'التقييم',
new: 'جديد',
publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
},
}[language]
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
const pickupLocation = typeof searchParams?.pickupLocation === 'string' ? searchParams.pickupLocation : city
const dropoffLocation = typeof searchParams?.dropoffLocation === 'string' ? searchParams.dropoffLocation : ''
const dropoffMode = searchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof searchParams?.pickupDate === 'string' ? searchParams.pickupDate : ''
const pickupTime = typeof searchParams?.pickupTime === 'string' ? searchParams.pickupTime : '10:00'
const returnDate = typeof searchParams?.returnDate === 'string' ? searchParams.returnDate : ''
const returnTime = typeof searchParams?.returnTime === 'string' ? searchParams.returnTime : '10:00'
const promoCode = typeof searchParams?.promoCode === 'string' ? searchParams.promoCode : ''
const driverAge = typeof searchParams?.driverAge === 'string' ? searchParams.driverAge : '23+'
const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
const make = typeof searchParams?.make === 'string' ? searchParams.make : ''
const model = typeof searchParams?.model === 'string' ? searchParams.model : ''
const startDate = typeof searchParams?.startDate === 'string' ? searchParams.startDate : ''
const endDate = typeof searchParams?.endDate === 'string' ? searchParams.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([
marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []),
marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []),
marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/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.marketplacePartners}</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.marketplacePartner}</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>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
const copy = {
en: {
kicker: 'Features',
title: 'Operations, marketplace discovery, and branded booking.',
body: "RentalDriveGo combines company-only operations with a public marketplace that sends renters to each company's own payment flow.",
cards: [
['Private dashboard', 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.'],
['Marketplace discovery', 'Published vehicles and public offers appear on `/explore` for cross-company browsing.'],
['Branded booking site', 'Each company gets its own subdomain where renters complete booking and payment directly.'],
['Payment flexibility', 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.'],
['Advanced rental ops', 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.'],
['Notification controls', 'Both company staff and renters can manage event-by-channel notification preferences.'],
],
},
fr: {
kicker: 'Fonctionnalités',
title: 'Opérations, découverte marketplace et réservation de marque.',
body: "RentalDriveGo combine des opérations privées pour l'entreprise avec une marketplace publique qui redirige les clients vers le parcours de paiement propre à chaque société.",
cards: [
['Tableau de bord privé', "Flotte, réservations, CRM, permissions d'équipe, facturation et rapports restent isolés par entreprise."],
['Découverte marketplace', 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.'],
['Site de réservation de marque', 'Chaque entreprise dispose de son propre sous-domaine où les clients finalisent la réservation et le paiement.'],
['Flexibilité de paiement', 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.'],
['Opérations avancées', 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées sont gérés dans les paramètres du tableau de bord.'],
['Contrôle des notifications', 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.'],
],
},
ar: {
kicker: 'المزايا',
title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
cards: [
['لوحة تحكم خاصة', 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.'],
['اكتشاف عبر السوق', 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.'],
['موقع حجز مخصص', 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.'],
['مرونة الدفع', 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.'],
['عمليات متقدمة', 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.'],
['التحكم في الإشعارات', 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.'],
],
},
} as const
export default function FeaturesPage() {
const { language } = useMarketplacePreferences()
const dict = copy[language]
return (
<main className="site-page">
<div className="site-section space-y-12">
<div className="max-w-3xl">
<p className="site-kicker">{dict.kicker}</p>
<h1 className="site-title">{dict.title}</h1>
<p className="site-lead">{dict.body}</p>
</div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{dict.cards.map(([title, body]) => (
<article key={title} className="card p-8">
<h2 className="text-xl font-black text-stone-900 dark:text-stone-100">{title}</h2>
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-400">{body}</p>
</article>
))}
</div>
</div>
</main>
)
}
@@ -0,0 +1,34 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
const navigation = vi.hoisted(() => ({
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
}))
vi.mock('next/navigation', () => navigation)
import FooterContentPage from '@/components/FooterContentPage'
import FooterPage, { generateStaticParams } from './page'
import { footerPageSlugs } from '@/lib/footerContent'
describe('marketplace footer route', () => {
it('generates one static route per registered footer slug', () => {
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
})
it('passes valid slugs through to FooterContentPage', () => {
const element = FooterPage({ params: { slug: 'privacy-policy' } })
expect(isValidElement(element)).toBe(true)
expect(element.type).toBe(FooterContentPage)
expect(element.props.slug).toBe('privacy-policy')
expect(element.props.forcedLanguage).toBeUndefined()
})
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
expect(navigation.notFound).toHaveBeenCalled()
})
})
@@ -0,0 +1,21 @@
import { notFound } from 'next/navigation'
import FooterContentPage from '@/components/FooterContentPage'
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
export function generateStaticParams() {
return footerPageSlugs.map((slug) => ({ slug }))
}
export default function FooterPage({
params,
}: {
params: { slug: string }
}) {
const { slug } = params
if (!isFooterPageSlug(slug)) {
notFound()
}
return <FooterContentPage slug={slug} />
}
@@ -0,0 +1,41 @@
'use client'
import MarketplaceHeader from '@/components/MarketplaceHeader'
import MarketplaceFooter from '@/components/MarketplaceFooter'
import { useMarketplacePreferences, getFooterContent, localeOptions } from '@/components/MarketplaceShell'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function PublicLayout({ children }: { children: React.ReactNode }) {
const { language, theme, dict, companyName, setLanguage, setTheme } = useMarketplacePreferences()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const footerContent = getFooterContent(language)
const available = localeOptions.filter((o) => o.value !== language)
const current = localeOptions.find((o) => o.value === language) ?? localeOptions[0]
return (
<div className="site-page flex min-h-screen flex-col">
<MarketplaceHeader
dict={dict}
theme={theme}
setTheme={setTheme}
companyName={companyName}
dashboardUrl={dashboardUrl}
currentLanguage={{ value: current.value, flag: current.flag, shortLabel: current.value.toUpperCase() }}
localeOptions={available.map((o) => ({ value: o.value, flag: o.flag, shortLabel: o.value.toUpperCase() }))}
onSelectLanguage={setLanguage}
/>
<div className="flex flex-1 flex-col">
<div className="flex-1">{children}</div>
<MarketplaceFooter
primaryItems={footerContent.primary}
secondaryItems={footerContent.secondary}
localeLabel={footerContent.localeLabel}
rightsLabel={footerContent.rightsLabel}
localeOptions={available}
currentLocale={current}
onSelectLanguage={setLanguage}
/>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
import HomeContent from './HomeContent'
export default function HomePage() {
return <HomeContent />
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function PlatformOperationsPage() {
redirect('/')
}
@@ -0,0 +1,226 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { getCurrencyLabel, PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
type Billing = 'monthly' | 'annual'
type PricingMatrix = Record<string, Record<string, Record<string, number>>>
type PlanFeatureMap = Record<string, string[]>
type PlatformPricing = {
prices: PricingMatrix
planFeatures: PlanFeatureMap
}
export const DEFAULT_PRICING: PlatformPricing = {
prices: PLAN_PRICES,
planFeatures: PLAN_FEATURES,
}
const PLANS = [
{
key: 'STARTER',
name: 'Starter',
tagline: 'Launch your fleet online',
highlight: false,
},
{
key: 'GROWTH',
name: 'Growth',
tagline: 'Scale with confidence',
highlight: true,
},
{
key: 'PRO',
name: 'Pro',
tagline: 'Enterprise-grade power',
highlight: false,
},
]
const copy = {
en: {
monthly: 'Monthly',
annual: 'Annual',
yearly: '/ year',
youSave: 'You save',
withAnnual: 'with annual billing — billed as one payment per year.',
mostPopular: 'Most popular',
perMonth: '/mo',
billedAs: 'Billed as',
getStarted: 'Get started',
footer: 'All plans include a 90-day free trial. No credit card required.',
},
fr: {
monthly: 'Mensuel',
annual: 'Annuel',
yearly: '/ an',
youSave: 'Vous économisez',
withAnnual: 'avec la facturation annuelle, prélevée en un seul paiement par an.',
mostPopular: 'Le plus populaire',
perMonth: '/mois',
billedAs: 'Facturé à',
getStarted: 'Commencer',
footer: "Toutes les formules incluent un essai gratuit de 90 jours. Aucune carte bancaire n'est requise.",
},
ar: {
monthly: 'شهري',
annual: 'سنوي',
yearly: '/ سنة',
youSave: 'توفر',
withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
mostPopular: 'الأكثر شيوعاً',
perMonth: '/شهر',
billedAs: 'يتم الفوترة بمبلغ',
getStarted: 'ابدأ الآن',
footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
},
} as const
function getPlanPrice(prices: PricingMatrix, plan: string, billingPeriod: 'MONTHLY' | 'ANNUAL') {
return (prices[plan]?.[billingPeriod]?.MAD ?? PLAN_PRICES[plan]?.[billingPeriod]?.MAD ?? 0) / 100
}
function annualSavingsPct(prices: PricingMatrix, plan: string): number {
const monthly = getPlanPrice(prices, plan, 'MONTHLY')
const annual = getPlanPrice(prices, plan, 'ANNUAL')
const wouldPay = monthly * 12
if (wouldPay <= 0) return 0
return Math.max(0, Math.round(((wouldPay - annual) / wouldPay) * 100))
}
export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { initialPricing?: PlatformPricing }) {
const { language } = useMarketplacePreferences()
const currencyLabel = getCurrencyLabel(language)
const dict = copy[language]
const [billing, setBilling] = useState<Billing>('monthly')
const [pricing, setPricing] = useState<PlatformPricing>(initialPricing)
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
useEffect(() => {
marketplaceFetchOrDefault<PlatformPricing>('/site/platform/pricing', DEFAULT_PRICING)
.then(setPricing)
}, [])
const savings = annualSavingsPct(pricing.prices, 'STARTER')
return (
<div className="space-y-10">
{/* Billing toggle */}
<div className="flex items-center justify-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-blue-950">
{(['monthly', 'annual'] as Billing[]).map((b) => (
<button
key={b}
onClick={() => setBilling(b)}
className={`relative rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
billing === b
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100'
}`}
>
{b === 'monthly' ? dict.monthly : dict.annual}
{b === 'annual' && billing !== 'annual' && (
<span className="ml-2 rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-bold text-orange-700 dark:bg-orange-950/50 dark:text-orange-400">
-{savings}%
</span>
)}
</button>
))}
</div>
{billing === 'annual' && (
<p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400">
{dict.youSave} {savings}% {dict.withAnnual}
</p>
)}
{/* Plan cards */}
<div className="grid gap-6 md:grid-cols-3">
{PLANS.map((plan) => {
const monthlyPrice = getPlanPrice(pricing.prices, plan.key, 'MONTHLY')
const annualPrice = getPlanPrice(pricing.prices, plan.key, 'ANNUAL')
const displayPrice = billing === 'annual'
? Math.round(annualPrice / 12)
: monthlyPrice
const features = pricing.planFeatures[plan.key] ?? PLAN_FEATURES[plan.key] ?? []
return (
<div
key={plan.key}
className={`card relative flex flex-col overflow-hidden ${
plan.highlight
? 'border-orange-400 ring-2 ring-orange-400 ring-offset-2 dark:ring-offset-blue-950'
: ''
}`}
>
{plan.highlight && (
<div className="bg-orange-500 dark:bg-orange-400 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white dark:text-stone-900">
{dict.mostPopular}
</div>
)}
<div className="flex flex-1 flex-col p-8">
<p className="text-xs font-bold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-400">
{plan.name}
</p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{plan.tagline}</p>
<div className="mt-6">
<div className="flex items-end gap-1">
<span className="text-4xl font-black text-stone-900 dark:text-stone-100">
{displayPrice}
</span>
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">{currencyLabel}</span>
<span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span>
</div>
{billing === 'annual' && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
{dict.billedAs} {annualPrice} {currencyLabel} {dict.yearly}
</p>
)}
</div>
<ul className="mt-8 flex-1 space-y-3">
{features.map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700 dark:text-stone-300">
<svg
className="mt-0.5 h-4 w-4 shrink-0 text-orange-500 dark:text-orange-400"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{f}
</li>
))}
</ul>
<Link
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}&currency=MAD`}
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
plan.highlight
? 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-400 dark:text-stone-900 dark:hover:bg-orange-300'
: 'bg-blue-900 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400'
}`}
>
{dict.getStarted}
</Link>
</div>
</div>
)
})}
</div>
{/* Footer note */}
<p className="text-center text-sm text-stone-400 dark:text-stone-500">{dict.footer}</p>
</div>
)
}
@@ -0,0 +1,74 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import PricingClient from './PricingClient'
const copy = {
en: {
kicker: 'Pricing',
title: 'One platform, every fleet size.',
body: 'Start free for 90 days, then choose the plan that grows with your business. Switch plans or cancel at any time.',
faq: 'Frequently asked',
items: [
['Can I change plans later?', 'Yes. Upgrade or downgrade at any time — changes take effect on your next billing cycle.'],
['What counts as a vehicle?', 'Any vehicle record added to your fleet dashboard, active or not.'],
['Is there a setup fee?', 'No setup fee, ever. You only pay the monthly or annual subscription.'],
['How does the marketplace listing work?', 'All paid plans include a public marketplace listing on RentalDriveGo. Renters can discover your fleet and are redirected to your branded booking site.'],
],
},
fr: {
kicker: 'Tarifs',
title: 'Une seule plateforme pour toutes les tailles de flotte.',
body: 'Commencez gratuitement pendant 90 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
faq: 'Questions fréquentes',
items: [
['Puis-je changer de formule plus tard ?', 'Oui. Vous pouvez passer à une formule supérieure ou inférieure à tout moment. Les changements prennent effet au cycle de facturation suivant.'],
["Qu'est-ce qui compte comme véhicule ?", "Tout véhicule ajouté à votre tableau de bord de flotte, qu'il soit actif ou non."],
["Y a-t-il des frais d'installation ?", "Aucun frais d'installation. Vous payez uniquement l'abonnement mensuel ou annuel."],
['Comment fonctionne la visibilité marketplace ?', 'Toutes les formules payantes incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
],
},
ar: {
kicker: 'الأسعار',
title: 'منصة واحدة لكل أحجام الأساطيل.',
body: 'ابدأ مجاناً لمدة 14 يوماً، ثم اختر الخطة التي تناسب نمو نشاطك. يمكنك تغيير الخطة أو إلغاءها في أي وقت.',
faq: 'الأسئلة الشائعة',
items: [
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط قيمة الاشتراك الشهري أو السنوي.'],
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
],
},
} as const
export default function PricingPageContent({ initialPricing }: { initialPricing?: React.ComponentProps<typeof PricingClient>['initialPricing'] }) {
const { language } = useMarketplacePreferences()
const dict = copy[language]
return (
<main className="site-page">
<div className="site-section">
<div className="mx-auto max-w-2xl text-center">
<p className="site-kicker">{dict.kicker}</p>
<h1 className="site-title">{dict.title}</h1>
<p className="site-lead">{dict.body}</p>
</div>
<div className="mt-16">
<PricingClient initialPricing={initialPricing} />
</div>
<div className="site-panel mx-auto mt-24 max-w-3xl divide-y divide-stone-200/80 dark:divide-stone-800">
<h2 className="pb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.faq}</h2>
{dict.items.map(([q, a]) => (
<div key={q} className="py-6">
<p className="font-semibold text-stone-900 dark:text-stone-100">{q}</p>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-stone-400">{a}</p>
</div>
))}
</div>
</div>
</main>
)
}
@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { marketplaceFetchOrDefault } from '@/lib/api'
import PricingPageContent from './PricingPageContent'
export const metadata: Metadata = {
title: 'Pricing — RentalDriveGo',
description: 'Simple, transparent pricing for every fleet size.',
}
const DEFAULT_PRICING = {
prices: PLAN_PRICES,
planFeatures: PLAN_FEATURES,
}
export default async function PricingPage() {
const initialPricing = await marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
return <PricingPageContent initialPricing={initialPricing} />
}
@@ -0,0 +1,212 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Star } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
interface ReviewInfo {
reservationId: string
companyName: string
companyLogoUrl: string | null
vehicle: string
vehiclePhoto: string | null
}
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
const [hovered, setHovered] = useState(0)
return (
<div>
<p className="mb-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">{label}</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
onMouseEnter={() => setHovered(n)}
onMouseLeave={() => setHovered(0)}
className="p-0.5 transition-transform hover:scale-110"
aria-label={`${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={`h-8 w-8 transition-colors ${
n <= (hovered || value)
? 'fill-orange-400 text-orange-400'
: 'fill-stone-200 text-stone-200 dark:fill-stone-700 dark:text-stone-700'
}`}
/>
</button>
))}
</div>
</div>
)
}
function ReviewPageContent() {
const params = useSearchParams()
const token = params.get('token') ?? ''
const [info, setInfo] = useState<ReviewInfo | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
const [overall, setOverall] = useState(0)
const [vehicle, setVehicle] = useState(0)
const [service, setService] = useState(0)
const [comment, setComment] = useState('')
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
if (!token) { setLoadState('invalid'); return }
fetch(`${API_BASE}/marketplace/review/${token}`)
.then(async (r) => {
if (r.status === 409) { setLoadState('already_reviewed'); return }
if (!r.ok) { setLoadState('invalid'); return }
const json = await r.json()
setInfo(json.data)
setLoadState('ready')
})
.catch(() => setLoadState('invalid'))
}, [token])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
setSubmitState('submitting')
setErrorMsg('')
try {
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
overallRating: overall,
vehicleRating: vehicle || undefined,
serviceRating: service || undefined,
comment: comment.trim() || undefined,
}),
})
if (res.status === 409) { setLoadState('already_reviewed'); return }
if (!res.ok) {
const json = await res.json()
throw new Error(json.message ?? 'Something went wrong.')
}
setSubmitState('done')
} catch (err: any) {
setSubmitState('error')
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
}
}
if (loadState === 'loading') {
return (
<main className="site-page flex min-h-screen items-center justify-center p-6">
<p className="text-sm text-stone-500 dark:text-stone-400">Loading</p>
</main>
)
}
if (loadState === 'invalid') {
return (
<main className="site-page flex min-h-screen items-center justify-center p-6">
<div className="card w-full max-w-md space-y-3 p-8 text-center">
<p className="text-2xl font-black text-stone-900 dark:text-stone-100">Link not found</p>
<p className="text-sm text-stone-500 dark:text-stone-400">This review link is invalid or has already been used.</p>
</div>
</main>
)
}
if (loadState === 'already_reviewed') {
return (
<main className="site-page flex min-h-screen items-center justify-center p-6">
<div className="card w-full max-w-md space-y-3 p-8 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
<svg className="h-7 w-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<p className="text-xl font-bold text-stone-900 dark:text-stone-100">Already reviewed</p>
<p className="text-sm text-stone-500 dark:text-stone-400">You have already submitted a review for this rental. Thank you!</p>
</div>
</main>
)
}
if (submitState === 'done') {
return (
<main className="site-page flex min-h-screen items-center justify-center p-6">
<div className="card w-full max-w-md space-y-4 p-8 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
<svg className="h-8 w-8 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<h1 className="text-2xl font-black text-stone-900 dark:text-stone-100">Thank you!</h1>
<p className="text-stone-500 dark:text-stone-400">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
</div>
</main>
)
}
return (
<main className="site-page flex min-h-screen items-center justify-center p-6">
<div className="card w-full max-w-lg overflow-hidden">
{/* Header */}
<div className="flex items-center gap-4 border-b border-stone-200 dark:border-blue-900 p-6">
{info?.vehiclePhoto ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 flex-shrink-0 rounded-xl object-cover" />
) : (
<div className="h-16 w-24 flex-shrink-0 rounded-xl bg-stone-100 dark:bg-blue-900/30" />
)}
<div className="min-w-0">
<p className="truncate text-xs uppercase tracking-widest text-stone-400 dark:text-stone-500">{info?.companyName}</p>
<h1 className="mt-0.5 truncate text-lg font-bold text-stone-900 dark:text-stone-100">{info?.vehicle}</h1>
<p className="mt-0.5 text-sm text-stone-500 dark:text-stone-400">How was your experience?</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6 p-6">
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">Comments (optional)</label>
<textarea
rows={4}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Tell us about your rental experience…"
className="w-full resize-none rounded-xl border border-stone-200 dark:border-blue-800 bg-white dark:bg-blue-950 px-4 py-3 text-sm text-stone-900 dark:text-stone-100 placeholder:text-stone-400 dark:placeholder:text-stone-500 focus:outline-none focus:ring-2 focus:ring-stone-900 dark:focus:ring-stone-400"
/>
</div>
{errorMsg && (
<p className="rounded-xl bg-rose-50 dark:bg-rose-950/40 px-4 py-3 text-sm text-rose-700 dark:text-rose-400">{errorMsg}</p>
)}
<button
type="submit"
disabled={submitState === 'submitting'}
className="w-full rounded-full bg-orange-600 py-3 text-sm font-semibold text-white transition-colors hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
>
{submitState === 'submitting' ? 'Submitting' : 'Submit review'}
</button>
</form>
</div>
</main>
)
}
export default function ReviewPage() {
return (
<Suspense
fallback={
<main className="site-page flex min-h-screen items-center justify-center p-6">
<p className="text-sm text-stone-500 dark:text-stone-400">Loading</p>
</main>
}
>
<ReviewPageContent />
</Suspense>
)
}
+108
View File
@@ -0,0 +1,108 @@
@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;
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import MarketplaceShell from '@/components/MarketplaceShell'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import './globals.css'
export const metadata: Metadata = {
title: 'RentalDriveGo Marketplace',
description: 'Discover vehicles from trusted rental companies.',
icons: {
icon: '/rentalcardrive.png',
shortcut: '/favicon.ico',
apple: '/rentalcardrive.png',
},
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const language = await getMarketplaceLanguage()
const cookieStore = await cookies()
const rawTheme =
cookieStore.get('rentaldrivego-theme')?.value ??
cookieStore.get('marketplace-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('marketplace-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>
<MarketplaceShell initialLanguage={language} initialTheme={theme}>{children}</MarketplaceShell>
</body>
</html>
)
}
@@ -0,0 +1,304 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
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 } = useMarketplacePreferences()
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 marketplace.',
startExploring: 'Start exploring',
rentalCompany: 'Rental company',
},
fr: {
loadProfile: 'Impossible de charger le profil.',
reachServer: 'Impossible de joindre le serveur.',
backToExplore: 'Retour à lexploration',
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 marketplace.',
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>
)
}
@@ -0,0 +1,5 @@
import RenterShell from '@/components/RenterShell'
export default function RenterLayout({ children }: { children: React.ReactNode }) {
return <RenterShell>{children}</RenterShell>
}
@@ -0,0 +1,153 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
RenterAuthError,
loadRenterNotifications,
markAllRenterNotificationsRead,
markRenterNotificationRead,
type RenterNotification,
} from '@/lib/renter'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
export default function RenterNotificationsPage() {
const router = useRouter()
const { language } = useMarketplacePreferences()
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>
)
}
@@ -0,0 +1,318 @@
'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 { useMarketplacePreferences } from '@/components/MarketplaceShell'
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 } = useMarketplacePreferences()
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 lemail 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 denregistrer le profil.',
prefFailed: 'Impossible denregistrer 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>
)
}
@@ -0,0 +1,103 @@
'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 { useMarketplacePreferences } from '@/components/MarketplaceShell'
export default function RenterSavedCompaniesPage() {
const router = useRouter()
const { language } = useMarketplacePreferences()
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 marketplace',
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 navez encore enregistré aucune entreprise.',
browse: 'Parcourir la marketplace',
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>
)
}
@@ -0,0 +1,13 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function RenterSignInPage() {
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'),
)
redirect(`${dashboardUrl}/sign-in`)
}
@@ -0,0 +1,13 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function RenterSignUpPage() {
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'),
)
redirect(`${dashboardUrl}/sign-in`)
}
+39
View File
@@ -0,0 +1,39 @@
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: 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 = searchParams
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
}
@@ -0,0 +1,29 @@
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()
})
})
@@ -0,0 +1,557 @@
'use client'
import { useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { marketplacePost } from '@/lib/api'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
type Props = {
vehicleId: string
companySlug: string
dailyRate: number
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
const copy = {
en: {
title: 'Reserve this vehicle',
renterIdentity: 'Renter identity',
firstName: 'First name',
lastName: 'Last name',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
identityDocumentNumber: 'CIN / Passport number',
fullAddress: 'Full address',
driverLicenseSection: 'Driver license',
email: 'Email',
phone: 'Phone',
driverLicense: 'License number',
licenseIssuedAt: 'Issue date',
licenseExpiry: 'Expiry date',
licenseCountry: 'Country of issue',
licenseCategory: 'License category',
internationalLicenseNumber: 'International permit number (optional)',
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: 'Notes (optional)',
submit: 'Request reservation',
submitting: 'Sending…',
successTitle: 'Request received!',
successBody: 'Your reservation request has been sent. The company will review it and contact you shortly.',
invalidDates: 'Return date must be after pick-up date.',
required: 'Please fill in all required fields.',
perDay: '/ day',
estimatedTotal: 'Estimated total',
days: 'day(s)',
genericError: 'Something went wrong. Please try again.',
locationRequired: 'Please select the required pick-up and drop-off locations.',
},
fr: {
title: 'Réserver ce véhicule',
renterIdentity: 'Identité du locataire',
firstName: 'Prénom',
lastName: 'Nom',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
identityDocumentNumber: 'N° CIN / passeport',
fullAddress: 'Adresse complète',
driverLicenseSection: 'Permis de conduire',
email: 'E-mail',
phone: 'Téléphone',
driverLicense: 'Numéro du permis',
licenseIssuedAt: 'Date de délivrance',
licenseExpiry: 'Date dexpiration',
licenseCountry: 'Pays de délivrance',
licenseCategory: 'Catégorie du permis',
internationalLicenseNumber: 'N° de permis international (optionnel)',
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: 'Notes (optionnelles)',
submit: 'Demander une réservation',
submitting: 'Envoi…',
successTitle: 'Demande reçue !',
successBody: "Votre demande de réservation a été envoyée. Lentreprise va lexaminer et vous contactera prochainement.",
invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Veuillez remplir tous les champs obligatoires.',
perDay: '/ jour',
estimatedTotal: 'Total estimé',
days: 'jour(s)',
genericError: 'Une erreur est survenue. Veuillez réessayer.',
locationRequired: 'Veuillez sélectionner les lieux de départ et de retour requis.',
},
ar: {
title: 'احجز هذه السيارة',
renterIdentity: 'بيانات المستأجر',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
fullAddress: 'العنوان الكامل',
driverLicenseSection: 'رخصة القيادة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
driverLicense: 'رقم الرخصة',
licenseIssuedAt: 'تاريخ الإصدار',
licenseExpiry: 'تاريخ الانتهاء',
licenseCountry: 'بلد الإصدار',
licenseCategory: 'فئة الرخصة',
internationalLicenseNumber: 'رقم الرخصة الدولية (اختياري)',
pickupLocation: 'موقع الاستلام',
returnLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
startDate: 'تاريخ الاستلام',
endDate: 'تاريخ الإرجاع',
notes: 'ملاحظات (اختيارية)',
submit: 'طلب حجز',
submitting: 'جارٍ الإرسال…',
successTitle: 'تم استلام الطلب!',
successBody: 'تم إرسال طلب الحجز. ستراجعه الشركة وستتواصل معك قريباً.',
invalidDates: 'يجب أن يكون تاريخ الإعادة بعد تاريخ الاستلام.',
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
perDay: '/ يوم',
estimatedTotal: 'الإجمالي التقديري',
days: 'يوم',
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
locationRequired: 'يرجى اختيار مواقع الاستلام والإرجاع المطلوبة.',
},
} as const
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: 'same' | 'different'
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
}
export default function BookingForm({
vehicleId,
companySlug,
dailyRate,
pickupLocations,
allowDifferentDropoff,
dropoffLocations,
}: Props) {
const { language } = useMarketplacePreferences()
const t = copy[language]
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [dateOfBirth, setDateOfBirth] = useState('')
const [nationality, setNationality] = useState('')
const [identityDocumentNumber, setIdentityDocumentNumber] = useState('')
const [fullAddress, setFullAddress] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [internationalLicenseNumber, setInternationalLicenseNumber] = useState('')
const [pickupLocation, setPickupLocation] = useState(pickupOptions[0] ?? '')
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>('same')
const [returnLocation, setReturnLocation] = useState(dropoffOptions[0] ?? '')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const totalDays = calculateBookingTotalDays(startDate, endDate)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (
!firstName.trim() ||
!lastName.trim() ||
!dateOfBirth ||
!nationality.trim() ||
!identityDocumentNumber.trim() ||
!fullAddress.trim() ||
!email.trim() ||
!phone.trim() ||
!driverLicense.trim() ||
!licenseIssuedAt ||
!licenseExpiry ||
!licenseCountry.trim() ||
!licenseCategory.trim() ||
!startDate ||
!endDate
) {
setError(t.required)
return
}
if ((pickupOptions.length > 0 && !pickupLocation) || (dropoffMode === 'different' && dropoffOptions.length > 0 && !returnLocation)) {
setError(t.locationRequired)
return
}
const start = new Date(startDate)
const end = new Date(endDate)
if (end <= start) {
setError(t.invalidDates)
return
}
setSubmitting(true)
try {
await marketplacePost('/marketplace/reservations', {
vehicleId,
companySlug,
firstName: firstName.trim(),
lastName: lastName.trim(),
dateOfBirth: new Date(dateOfBirth).toISOString(),
nationality: nationality.trim(),
identityDocumentNumber: identityDocumentNumber.trim(),
fullAddress: fullAddress.trim(),
email: email.trim(),
phone: phone.trim(),
driverLicense: driverLicense.trim(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseCategory: licenseCategory.trim(),
internationalLicenseNumber: normalizeOptionalText(internationalLicenseNumber),
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
returnLocation: resolveReturnLocation({ dropoffMode, returnLocation, pickupLocation }),
notes: normalizeOptionalText(notes),
language,
})
setSuccess(true)
} catch (err: any) {
setError(err.message ?? t.genericError)
} 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>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.title}</p>
{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}
<div className="space-y-4 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.renterIdentity}</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.firstName} <span className="text-red-500">*</span></label>
<input
required
value={firstName}
onChange={(e) => setFirstName(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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.lastName} <span className="text-red-500">*</span></label>
<input
required
value={lastName}
onChange={(e) => setLastName(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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.dateOfBirth} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.nationality} <span className="text-red-500">*</span></label>
<input
required
value={nationality}
onChange={(e) => setNationality(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"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.identityDocumentNumber} <span className="text-red-500">*</span></label>
<input
required
value={identityDocumentNumber}
onChange={(e) => setIdentityDocumentNumber(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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.fullAddress} <span className="text-red-500">*</span></label>
<textarea
required
value={fullAddress}
onChange={(e) => setFullAddress(e.target.value)}
rows={3}
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"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.email} <span className="text-red-500">*</span></label>
<input
required
type="email"
value={email}
onChange={(e) => setEmail(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"
/>
</div>
<div className="space-y-4 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.driverLicenseSection}</p>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.driverLicense} <span className="text-red-500">*</span></label>
<input
required
value={driverLicense}
onChange={(e) => setDriverLicense(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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseIssuedAt} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={licenseIssuedAt}
onChange={(e) => setLicenseIssuedAt(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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseExpiry} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={licenseExpiry}
onChange={(e) => setLicenseExpiry(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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseCountry} <span className="text-red-500">*</span></label>
<input
required
value={licenseCountry}
onChange={(e) => setLicenseCountry(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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseCategory} <span className="text-red-500">*</span></label>
<input
required
value={licenseCategory}
onChange={(e) => setLicenseCategory(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"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.internationalLicenseNumber}</label>
<input
value={internationalLicenseNumber}
onChange={(e) => setInternationalLicenseNumber(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"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.phone} <span className="text-red-500">*</span></label>
<input
required
type="tel"
value={phone}
onChange={(e) => setPhone(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"
/>
</div>
{pickupOptions.length > 0 ? (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.pickupLocation} <span className="text-red-500">*</span></label>
<select
value={pickupLocation}
onChange={(e) => setPickupLocation(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 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
<div className="inline-flex rounded-full border border-stone-200 dark:border-blue-800 bg-white dark:bg-blue-900/30 p-1">
<button
type="button"
onClick={() => setDropoffMode('same')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
dropoffMode === 'same'
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{t.sameDropoff}
</button>
<button
type="button"
onClick={() => setDropoffMode('different')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
dropoffMode === 'different'
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{t.differentDropoff}
</button>
</div>
{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} <span className="text-red-500">*</span></label>
<select
value={returnLocation}
onChange={(e) => setReturnLocation(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">{pickupLocation || t.sameAsPickup}</p>
)}
</div>
) : null}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.startDate} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={startDate}
min={new Date().toISOString().slice(0, 10)}
onChange={(e) => setStartDate(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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.endDate} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={endDate}
min={startDate || new Date().toISOString().slice(0, 10)}
onChange={(e) => setEndDate(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"
/>
</div>
</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}
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.notes}</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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"
/>
</div>
<button
type="submit"
disabled={submitting}
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>
)
}
@@ -0,0 +1,64 @@
import React, { isValidElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/MarketplaceShell', () => ({
useMarketplacePreferences: () => ({ 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 marketplace language when no forced language is provided', () => {
preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).toContain('Besoin de plus de détails')
})
it('lets static app policy pages force their own locale', () => {
preferenceState.language = 'en'
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).not.toContain('Footer information')
})
it('marks Arabic content as right-aligned', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
const elements = collectElements(page)
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
})
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
const links = collectElements(page).filter((element) => element.type === 'a')
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
})
})
@@ -0,0 +1,105 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type MarketplaceLanguage } 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?: MarketplaceLanguage }) {
const { language } = useMarketplacePreferences()
const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage]
const isArabic = activeLanguage === 'ar'
return (
<main className="site-page">
<div className="site-section mx-auto max-w-4xl">
<section className="site-panel overflow-hidden p-0">
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
<p className="site-kicker">
{meta.kicker}
</p>
<h1 className="site-title">
{content.title}
</h1>
</div>
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
{content.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
{content.sections?.map((section) => (
<section key={section.heading} className="space-y-4">
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
</section>
))}
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
{meta.cta}
</p>
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
</div>
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,123 @@
'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 MarketplaceFooter({
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">
&copy; {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>
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
buildDashboardSignInHref,
companyInitial,
localeMenuPositionClass,
ownerWorkspaceHref,
} from './MarketplaceHeader'
describe('MarketplaceHeader 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('ز')
})
})
@@ -0,0 +1,230 @@
'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 = {
marketplace: 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 MarketplaceHeader({
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="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
priority
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.marketplace}
</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>
<Link
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}
</Link>
{companyName ? (
<Link
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}
</Link>
) : (
<Link
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}
</Link>
)}
</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>
)
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './MarketplaceShell'
describe('MarketplaceShell footer content registry', () => {
it('exposes exactly the supported marketplace 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')
})
})
@@ -0,0 +1,336 @@
'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, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
type Dictionary = {
marketplace: string
explore: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
dark: string
preferences: string
}
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
en: {
marketplace: 'Home',
explore: 'Marketplace',
signIn: 'Sign in',
ownerSignIn: 'Create Agency Space',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
preferences: 'Marketplace preferences',
},
fr: {
marketplace: 'Accueil',
explore: 'Marketplace',
signIn: 'Connexion',
ownerSignIn: "Créer un espace d'agence",
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
preferences: 'Préférences marketplace',
},
ar: {
marketplace: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
preferences: 'تفضيلات السوق',
},
}
export const localeOptions: Array<{ value: MarketplaceLanguage; 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: MarketplaceLanguage): {
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.',
}
}
}
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: MarketplaceLanguage
theme: Theme
dict: Dictionary
companyName: string | null
setLanguage: (language: MarketplaceLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useMarketplacePreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
return context
}
export default function MarketplaceShell({
children,
initialLanguage = 'en',
initialTheme = 'light',
}: {
children: React.ReactNode
initialLanguage?: MarketplaceLanguage
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<MarketplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
function applyLanguage(nextLanguage: MarketplaceLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('marketplace-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-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, ['marketplace-theme'])
}
setThemeState(nextTheme)
}
async function syncCompanyBrand() {
try {
const response = await fetch(`${apiUrl}/companies/me/brand`, {
credentials: 'include',
})
if (!response.ok) {
setCompanyName(null)
return
}
const json = await response.json()
const name = json?.data?.displayName ?? json?.displayName ?? null
setCompanyName(name)
} catch {
setCompanyName(null)
}
}
function readSessionLanguage(): MarketplaceLanguage | null {
try {
const val = sessionStorage.getItem('marketplace-language')
return isMarketplaceLanguage(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 (isMarketplaceLanguage(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, ['marketplace-theme'])
}
}
useEffect(() => {
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
} else {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
if (isMarketplaceLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
}
}
}
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-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('marketplace-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-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, ['marketplace-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>
}
@@ -0,0 +1,223 @@
'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 { useMarketplacePreferences } from '@/components/MarketplaceShell'
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 } = useMarketplacePreferences()
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">
&copy; {new Date().getFullYear()} RentalDriveGo. {dict.rights}
</p>
</footer>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
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('sends users with an employee token directly to the dashboard frame path', () => {
stubBrowser({ cookie: 'other=1; employee_session=token_123' })
expect(getDefaultFramePath('sign-in')).toBe('/dashboard')
})
it('keeps unauthenticated visitors on the embedded sign-in path', () => {
stubBrowser({ cookie: 'marketplace-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)')
})
})
@@ -0,0 +1,111 @@
'use client'
import { useEffect, useState } from 'react'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useMarketplacePreferences } from './MarketplaceShell'
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 } = useMarketplacePreferences()
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>
)
}
@@ -0,0 +1,234 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import Image from 'next/image'
import Link from 'next/link'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function WorkspaceTabs() {
const { language, theme } = useMarketplacePreferences()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const copy = {
en: {
badge: 'Unified Workspace',
title: 'One launch point on port 3000 for marketplace discovery, company operations, and platform admin work.',
intro:
'The apps still run as separate Next.js surfaces internally, but the navbar now takes you straight to each workspace area without hunting for ports.',
sectionBadge: 'RentalDriveGo Marketplace',
sectionTitle: 'Browse local rental fleets without losing the company behind the wheel.',
sectionBodyA: 'Discovery happens here. Booking and payment happen on each rental company\'s own branded site.',
sectionBodyB: 'Customers only reach that branded booking site after selecting a vehicle from a company\'s fleet.',
exploreVehicles: 'Explore vehicles',
viewPricing: 'View pricing',
nowServing: 'Now serving',
promoTitle: 'Discovery, pricing, owner sign-in, and booking entry all stay anchored on the marketplace surface.',
promoBody:
'Company Workspace and Platform Operations stay available from the navbar, while branded booking starts after a renter picks a vehicle.',
customerPaths: 'Customer Paths',
ownerSignIn: 'Owner sign in',
pricing: 'Pricing',
workspaceAreas: 'Workspace Areas',
workspaceA: 'Company Workspace for fleet operations, team, billing, and analytics.',
workspaceB: 'Platform Operations for platform-level support and tenant oversight.',
workspaceC: 'Branded booking handoff begins only after a renter chooses a specific vehicle.',
},
fr: {
badge: 'Espace unifié',
title: 'Un point dentrée sur le port 3000 pour la découverte marketplace, les opérations de lentreprise et ladministration de la plateforme.',
intro:
'Les applications restent des interfaces Next.js séparées en interne, mais la barre de navigation mène directement à chaque espace sans avoir à changer de port manuellement.',
sectionBadge: 'Marketplace RentalDriveGo',
sectionTitle: 'Parcourez les flottes locales sans perdre lidentité de lentreprise qui gère la location.',
sectionBodyA: 'La découverte commence ici. La réservation et le paiement se font sur le site de réservation propre à chaque entreprise.',
sectionBodyB: 'Les clients accèdent à ce site de réservation seulement après avoir choisi un véhicule dans la flotte dune entreprise.',
exploreVehicles: 'Explorer les véhicules',
viewPricing: 'Voir les tarifs',
nowServing: 'Disponible',
promoTitle: 'La découverte, les tarifs, la connexion propriétaire et laccès à la réservation restent ancrés dans la marketplace.',
promoBody:
'Lespace entreprise et les opérations de la plateforme restent accessibles depuis la navigation, tandis que la réservation de marque commence après le choix dun véhicule.',
customerPaths: 'Parcours client',
ownerSignIn: 'Connexion propriétaire',
pricing: 'Tarifs',
workspaceAreas: 'Espaces de travail',
workspaceA: 'Espace entreprise pour la flotte, l’équipe, la facturation et lanalyse.',
workspaceB: 'Opérations plateforme pour le support global et la supervision multi-entreprises.',
workspaceC: 'Le transfert vers la réservation de marque commence seulement après le choix dun véhicule précis.',
},
ar: {
badge: 'مساحة موحدة',
title: 'نقطة دخول واحدة على المنفذ 3000 لاكتشاف السوق وعمليات الشركات وإدارة المنصة.',
intro:
'ما زالت التطبيقات تعمل كواجهات Next.js منفصلة داخلياً، لكن شريط التنقل ينقلك مباشرة إلى كل مساحة عمل من دون الحاجة إلى البحث عن المنافذ.',
sectionBadge: 'سوق RentalDriveGo',
sectionTitle: 'تصفح أساطيل التأجير المحلية مع الحفاظ على هوية الشركة التي تدير الحجز.',
sectionBodyA: 'الاكتشاف يبدأ هنا. الحجز والدفع يتمان على موقع الحجز الخاص بكل شركة.',
sectionBodyB: 'لا يصل العميل إلى موقع الحجز الخاص بالشركة إلا بعد اختيار سيارة من أسطول تلك الشركة.',
exploreVehicles: 'استكشف السيارات',
viewPricing: 'عرض الأسعار',
nowServing: 'المتاح الآن',
promoTitle: 'الاكتشاف والأسعار وتسجيل دخول المالك وبداية الحجز تبقى كلها داخل واجهة السوق.',
promoBody:
'تبقى مساحة الشركة وعمليات المنصة متاحتين من شريط التنقل، بينما يبدأ الحجز المرتبط بالعلامة التجارية بعد اختيار المستأجر لسيارة.',
customerPaths: 'مسارات العملاء',
ownerSignIn: 'دخول المالك',
pricing: 'الأسعار',
workspaceAreas: 'مساحات العمل',
workspaceA: 'مساحة الشركة لإدارة الأسطول والفريق والفوترة والتحليلات.',
workspaceB: 'عمليات المنصة للدعم والإشراف على الشركات على مستوى المنصة.',
workspaceC: 'الانتقال إلى موقع الحجز الخاص بالشركة يبدأ فقط بعد اختيار سيارة محددة.',
},
}[language]
return (
<main className={`min-h-screen transition-colors ${
theme === 'dark'
? 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.20),transparent_28%),linear-gradient(180deg,#0f1f4a,#112d6e)]'
: 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.08),transparent_28%),linear-gradient(180deg,#f5f8ff,white)]'
}`}>
<div className="shell py-16 sm:py-20">
<div className="max-w-4xl">
<div className="flex items-center gap-4">
<Link href="/" aria-label="RentalDriveGo home">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={72}
height={72}
className={`h-16 w-16 rounded-2xl object-contain p-1 shadow-sm ${
theme === 'dark' ? 'border border-orange-500/40 bg-blue-950' : 'border border-orange-200 bg-white'
}`}
/>
</Link>
<p className={`text-sm font-semibold uppercase tracking-[0.24em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.badge}
</p>
</div>
<h1 className={`mt-5 text-4xl font-black tracking-tight sm:text-6xl ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.title}
</h1>
<p className={`mt-6 max-w-3xl text-lg leading-8 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.intro}
</p>
</div>
<section className={`mt-10 overflow-hidden rounded-[2rem] border shadow-xl transition-colors ${
theme === 'dark'
? 'border-blue-900 bg-blue-950 shadow-black/20'
: 'border-stone-200 bg-white shadow-stone-200/40'
}`}>
<div className={`px-6 py-6 ${theme === 'dark' ? 'border-b border-stone-800' : 'border-b border-stone-200'}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.sectionBadge}
</p>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<h2 className={`text-2xl font-black tracking-tight ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.sectionTitle}
</h2>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyA}
</p>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyB}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/explore"
className={`rounded-full px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'bg-orange-400 text-blue-950 hover:bg-orange-300'
: 'bg-blue-950 text-white hover:bg-blue-900/40'
}`}
>
{copy.exploreVehicles}
</Link>
<Link
href="/pricing"
className={`rounded-full border px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'border-blue-800 text-slate-200 hover:border-blue-600 hover:bg-blue-900/40'
: 'border-stone-300 text-stone-700 hover:border-stone-400 hover:bg-stone-50'
}`}
>
{copy.viewPricing}
</Link>
</div>
</div>
</div>
<div className={`grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr_0.9fr] ${
theme === 'dark' ? 'bg-blue-950' : 'bg-stone-50'
}`}>
<div className="rounded-[1.5rem] bg-[linear-gradient(135deg,#06132e,#0d1b38)] p-8 text-white">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-300">{copy.nowServing}</p>
<h3 className="mt-4 text-3xl font-black tracking-tight">
{copy.promoTitle}
</h3>
<p className="mt-4 max-w-2xl text-sm leading-7 text-stone-200">
{copy.promoBody}
</p>
</div>
<div className="grid gap-4">
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.customerPaths}</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link href="/explore" className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white">
{copy.exploreVehicles}
</Link>
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href={`${dashboardUrl}/sign-in`}>
{copy.ownerSignIn}
</Link>
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href="/pricing">
{copy.pricing}
</Link>
</div>
</div>
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.workspaceAreas}</p>
<ul className={`mt-4 space-y-3 text-sm ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
<li>{copy.workspaceA}</li>
<li>{copy.workspaceB}</li>
<li>{copy.workspaceC}</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</main>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MarketplaceApiError, marketplaceFetch, marketplaceFetchOrDefault, marketplacePost } from './api'
afterEach(() => {
delete process.env.NEXT_PUBLIC_API_URL
delete process.env.API_INTERNAL_URL
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'fetch')
})
describe('marketplace 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(marketplaceFetch('/marketplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith('http://localhost:4000/api/v1/marketplace/vehicles', { cache: 'no-store' })
})
it('throws rich marketplace 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(marketplaceFetch('/marketplace/book')).rejects.toMatchObject({
name: 'MarketplaceApiError',
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(marketplaceFetchOrDefault('/marketplace/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(marketplacePost('/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',
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(marketplaceFetch('/site/homepage')).rejects.toMatchObject({
name: 'MarketplaceApiError',
message: 'Request failed',
status: 502,
})
})
})
+45
View File
@@ -0,0 +1,45 @@
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 MarketplaceApiError extends Error {
status: number
code?: string
nextAvailableAt?: string | null
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
super(message)
this.name = 'MarketplaceApiError'
this.status = status
this.code = code
this.nextAvailableAt = nextAvailableAt
}
}
export async function marketplaceFetch<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
const json = await res.json().catch(() => null)
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
return json.data as T
}
export async function marketplaceFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
try {
return await marketplaceFetch<T>(path)
} catch {
return fallback
}
}
export async function marketplacePost<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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
return json.data as T
}
+40
View File
@@ -0,0 +1,40 @@
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('marketplace 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/marketplace')).toBe('https://rental.example.com/marketplace')
})
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')
expect(resolveServerAppUrl('http://localhost:3000', null)).toBe('http://localhost:3000')
expect(resolveServerAppUrl('/relative', 'market.example.com')).toBe('/relative')
})
})
+28
View File
@@ -0,0 +1,28 @@
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
}
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
appPrivacyHref,
appTermsHref,
footerPageHref,
footerPageSlugs,
getFooterPageContent,
isFooterPageSlug,
} from './footerContent'
import type { MarketplaceLanguage } from './i18n'
const languages: MarketplaceLanguage[] = ['en', 'fr', 'ar']
describe('marketplace 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')
})
})
+790
View File
@@ -0,0 +1,790 @@
import type { MarketplaceLanguage } 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<MarketplaceLanguage, 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, marketplace-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 dutilisation',
paragraphs: [
'En utilisant RentalDriveGo, vous acceptez de respecter nos politiques, nos règles dutilisation 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 dassurer la confidentialité et la fiabilité des services.',
],
},
compliance: {
title: 'Conformité',
paragraphs: [
'RentalDriveGo sengage à 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 lapplication, veuillez visiter : https://www.rentaldrivego.ma/app-privacy-fr',
'RentalDriveGo respecte votre vie privée et sengage à 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 sappliquent 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 dutilisation et durée de conservation',
paragraphs: [
'Lorsque vous consultez notre site à des fins dinformation 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 lheure de la demande, ladresse IP, les pages consultées, le site dorigine, le volume de données transférées et le système dexploitation.',
'Ces informations sont temporairement conservées afin de permettre laffichage du site sur votre appareil, de réaliser des analyses internes, daméliorer la présentation du site et de détecter ou prévenir les abus. Nous ne croisons pas ces données avec dautres sources et nous ne cherchons pas à vous identifier directement à partir de celles-ci. Elles sont supprimées dès quelles 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 lamé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 dobligation légale, lorsque ce transfert est nécessaire à lexécution dune 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 lhé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 quaprè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 labsence dopposition valable.',
'Lorsque des délais légaux de conservation sappliquent, les données sont conservées pendant la durée requise. À lexpiration de cette période, elles sont supprimées sauf si elles restent nécessaires à lexécution ou à la préparation dun 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 dun droit daccès à vos données, dun droit dobtenir des informations complémentaires, dun droit de rectification des données inexactes et, le cas échéant, dun droit dopposition 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 dune copie de votre pièce didentité à : 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 nutilisons 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 dauthentification',
paragraphs: [
'Nom : employee_session',
'Ce cookie est défini lorsque vous vous connectez à lespace 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, laccès au tableau de bord et aux espaces protégés nest pas possible.',
],
},
{
heading: '2. Cookie de préférence de langue',
paragraphs: [
'Nom : rentaldrivego-language',
'Enregistre la langue daffichage choisie (français, anglais ou arabe) afin que linterface saffiche dans votre langue préférée à chaque visite sur lensemble 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 dappliquer le bon thème avant laffichage de linterface, é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, marketplace-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 quen 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 dauthentification 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 dutilisation',
paragraphs: [
'Vous consultez actuellement les Conditions générales dutilisation du site RentalDriveGo. Pour consulter les Conditions générales dutilisation de lapplication, veuillez visiter : https://www.rentaldrivego.ma/app-tc-fr',
'Les présentes Conditions générales dutilisation 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 lutilisation du site www.rentaldrivego.ma exploité par RentalDriveGo, société à responsabilité limitée immatriculée au registre du commerce dAzrou 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 sappliquent à toute personne qui visite le site, nous contacte ou contacte dautres 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 dinformation, 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 à ladresse service-client@rentaldrivego.ma.',
],
},
{
heading: '4. Acceptation des Conditions générales dutilisation',
paragraphs: [
'En utilisant le site, vous acceptez les présentes Conditions générales dutilisation 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 sappliquent également à votre utilisation du site.',
],
},
{
heading: '5. Modification des Conditions générales dutilisation',
paragraphs: [
'Nous pouvons modifier à tout moment les présentes Conditions générales dutilisation. À 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 laccè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 dinformation 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, quelles 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 laccès et lutilisation du site ainsi que des pages qui y sont rattachées se font à vos propres risques.',
'Aucune disposition des présentes conditions nexclut ou ne limite notre responsabilité lorsquune 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 lutilisation 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 lorsquune autre base légale sapplique.',
'Vous vous engagez à utiliser le site uniquement à des fins licites.',
'Si nous estimons quune violation des présentes Conditions générales dutilisation 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 lensemble 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, marketplace-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: MarketplaceLanguage, slug: FooterPageSlug): FooterPageContent {
return footerContent[language][slug]
}
@@ -0,0 +1,41 @@
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 { getMarketplaceLanguage } from './i18n.server'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE } from './i18n'
describe('getMarketplaceLanguage', () => {
beforeEach(() => {
cookieValues.clear()
})
it('prefers the shared language cookie over the legacy marketplace cookie', async () => {
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'ar')
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
await expect(getMarketplaceLanguage()).resolves.toBe('ar')
})
it('falls back to the legacy marketplace cookie during migration', async () => {
cookieValues.set(MARKETPLACE_LANGUAGE_COOKIE, 'fr')
await expect(getMarketplaceLanguage()).resolves.toBe('fr')
})
it('defaults to English when cookies are absent or invalid', async () => {
await expect(getMarketplaceLanguage()).resolves.toBe('en')
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
await expect(getMarketplaceLanguage()).resolves.toBe('en')
})
})
+10
View File
@@ -0,0 +1,10 @@
import { cookies } from 'next/headers'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from './i18n'
export async function getMarketplaceLanguage(): Promise<MarketplaceLanguage> {
const cookieStore = await cookies()
const cookieValue =
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
return isMarketplaceLanguage(cookieValue) ? cookieValue : 'en'
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { isMarketplaceLanguage } from './i18n'
describe('marketplace language guard', () => {
it('accepts the supported marketplace locales', () => {
expect(isMarketplaceLanguage('en')).toBe(true)
expect(isMarketplaceLanguage('fr')).toBe(true)
expect(isMarketplaceLanguage('ar')).toBe(true)
})
it('rejects unsupported, missing, and case-mismatched locales', () => {
expect(isMarketplaceLanguage('de')).toBe(false)
expect(isMarketplaceLanguage('EN')).toBe(false)
expect(isMarketplaceLanguage('')).toBe(false)
expect(isMarketplaceLanguage(null)).toBe(false)
expect(isMarketplaceLanguage(undefined)).toBe(false)
})
})
+8
View File
@@ -0,0 +1,8 @@
export type MarketplaceLanguage = 'en' | 'fr' | 'ar'
export const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export function isMarketplaceLanguage(value: string | null | undefined): value is MarketplaceLanguage {
return value === 'en' || value === 'fr' || value === 'ar'
}
@@ -0,0 +1,69 @@
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('marketplace 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('marketplace-language', 'ar')
expect(readScopedPreference('rentaldrivego-language', ['marketplace-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')
})
})
+94
View File
@@ -0,0 +1,94 @@
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)
}
}
+78
View File
@@ -0,0 +1,78 @@
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('http://localhost:4000/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('http://localhost:4000/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('http://localhost:4000/api/v1/notifications/renter/preferences', expect.objectContaining({
method: 'PATCH',
body: JSON.stringify(body),
}))
})
})
+107
View File
@@ -0,0 +1,107 @@
'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),
})
}
+104
View File
@@ -0,0 +1,104 @@
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 MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
kind: 'response',
body,
status: init?.status,
})) as any
MockNextResponse.next = next
return { next, 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 {
cookies: {
get: vi.fn((name: string) => {
const value = cookies.get(name)
return value ? { value } : undefined
}),
},
headers,
}
}
beforeEach(() => {
nextServer.next.mockClear()
})
describe('marketplace middleware language bootstrap', () => {
it('rejects middleware subrequest headers before language handling', async () => {
const { middleware } = await import('./middleware')
const response = middleware(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 { middleware } = await import('./middleware')
const response = middleware(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 marketplace language cookie into the shared cookie and request headers', async () => {
const { middleware } = await import('./middleware')
const response = middleware(request({
cookies: { 'marketplace-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 { middleware } = await import('./middleware')
const arabic = middleware(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 = middleware(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 = middleware(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 { middleware } = await import('./middleware')
const response = middleware(request({
cookies: { 'rentaldrivego-language': 'es', 'marketplace-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')
})
})
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
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'
}
export function middleware(request: NextRequest) {
const rejected = rejectInternalSubrequest(request)
if (rejected) return rejected
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
// Already has the canonical language cookie — no action needed
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'))
// Inject the resolved language into the request cookie header so that
// getMarketplaceLanguage() (which calls cookies()) sees the correct value
// even on this very first render — before the browser has stored the cookie.
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 } })
// Persist in the browser for future requests
response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, {
maxAge: 365 * 24 * 60 * 60,
path: '/',
sameSite: 'lax',
})
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:png|ico|jpg|jpeg|svg|webp)$).*)',
],
}