fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -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')
})
})
@@ -21,7 +21,7 @@ interface CompanyProfile {
}
export default async function CompanyProfilePage({ params }: { params: { slug: string } }) {
const language = getMarketplaceLanguage()
const language = await getMarketplaceLanguage()
const dict = {
en: {
unavailable: 'Marketplace unavailable',
@@ -33,7 +33,7 @@ interface VehicleDetail {
}
export default async function VehicleDetailPage({ params }: { params: { slug: string; id: string } }) {
const language = getMarketplaceLanguage()
const language = await getMarketplaceLanguage()
const dict = {
en: {
unavailable: 'Marketplace unavailable',
@@ -67,7 +67,7 @@ const CATEGORY_VALUES = [
] as const
export default async function ExplorePage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
const language = getMarketplaceLanguage()
const language = await getMarketplaceLanguage()
const dict = {
en: {
kicker: 'Discovery only',
@@ -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()
})
})
+3 -3
View File
@@ -14,9 +14,9 @@ export const metadata: Metadata = {
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
const language = getMarketplaceLanguage()
const cookieStore = cookies()
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
@@ -113,20 +113,14 @@ export default function RenterDashboardPage() {
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
const token = localStorage.getItem('renter_token')
if (!token) {
router.replace('/')
return
}
fetch(`${API_BASE}/auth/renter/me`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
.then(async (res) => {
const json = await res.json().catch(() => null)
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('renter_token')
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
router.replace('/')
} else {
setErrorMsg(json?.message ?? dict.loadProfile)
@@ -2,8 +2,8 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function RenterSignInPage() {
const requestHeaders = headers()
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'),
@@ -2,8 +2,8 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function RenterSignUpPage() {
const requestHeaders = headers()
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'),
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function SignInPage({
}: {
searchParams: SearchParams
}) {
const requestHeaders = headers()
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
requestHeaders.get('host'),
@@ -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()
})
})
@@ -131,6 +131,30 @@ const copy = {
},
} 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,
@@ -169,10 +193,7 @@ export default function BookingForm({
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const totalDays =
startDate && endDate
? Math.max(0, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86_400_000))
: 0
const totalDays = calculateBookingTotalDays(startDate, endDate)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -228,12 +249,12 @@ export default function BookingForm({
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseCategory: licenseCategory.trim(),
internationalLicenseNumber: internationalLicenseNumber.trim() || undefined,
internationalLicenseNumber: normalizeOptionalText(internationalLicenseNumber),
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
returnLocation: dropoffMode === 'different' ? (returnLocation || undefined) : (pickupLocation || undefined),
notes: notes.trim() || undefined,
returnLocation: resolveReturnLocation({ dropoffMode, returnLocation, pickupLocation }),
notes: normalizeOptionalText(notes),
language,
})
setSuccess(true)
@@ -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,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('ز')
})
})
@@ -5,8 +5,8 @@ import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type Theme = 'light' | 'dark'
type Language = 'en' | 'fr' | 'ar'
export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
marketplace: string
@@ -24,6 +24,26 @@ type LanguageMeta = {
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,
@@ -52,10 +72,7 @@ export default function MarketplaceHeader({
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const localeMenuPositionClass =
currentLanguage.value === 'ar'
? 'right-0 sm:right-0'
: 'left-0 sm:left-auto sm:right-0'
const menuPositionClass = localeMenuPositionClass(currentLanguage.value)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
@@ -81,10 +98,7 @@ export default function MarketplaceHeader({
setThemeMenuOpen((open) => !open)
}
const signInParams = new URLSearchParams()
signInParams.set('lang', currentLanguage.value)
signInParams.set('theme', theme)
const signInHref = `${dashboardUrl}/sign-in?${signInParams.toString()}`
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)]">
@@ -129,13 +143,13 @@ export default function MarketplaceHeader({
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">
{companyName.charAt(0).toUpperCase()}
{companyInitial(companyName)}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-up`}
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}
@@ -156,7 +170,7 @@ export default function MarketplaceHeader({
<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)] ${localeMenuPositionClass}`}>
<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}
@@ -189,7 +203,7 @@ export default function MarketplaceHeader({
<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)] ${localeMenuPositionClass}`}>
<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) => (
@@ -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')
})
})
@@ -201,20 +201,9 @@ export default function MarketplaceShell({
}
async function syncCompanyBrand() {
const token = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('employee_token='))
?.split('=')[1]
if (!token) {
setCompanyName(null)
return
}
try {
const response = await fetch(`${apiUrl}/companies/me/brand`, {
headers: { Authorization: `Bearer ${token}` },
credentials: 'include',
})
if (!response.ok) {
@@ -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('keeps users with an employee token on the embedded sign-in path until the dashboard confirms login', () => {
stubBrowser({ cookie: 'other=1; employee_session=token_123' })
expect(getDefaultFramePath('sign-in')).toBe('/dashboard/sign-in')
})
it('keeps unauthenticated visitors on the embedded sign-in path', () => {
stubBrowser({ cookie: '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)')
})
})
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useMarketplacePreferences } from './MarketplaceShell'
type FrameId = 'sign-in'
export type FrameId = 'sign-in'
const frameConfig: Record<FrameId, { label: string; appUrl: string; path: string }> = {
'sign-in': {
@@ -14,24 +14,15 @@ const frameConfig: Record<FrameId, { label: string; appUrl: string; path: string
},
}
function getDefaultFramePath(target: FrameId) {
export function getDefaultFramePath(target: FrameId) {
if (typeof window === 'undefined') {
return frameConfig[target].path
}
const employeeToken = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('employee_token='))
if (target === 'sign-in' && employeeToken) {
return '/dashboard'
}
return frameConfig[target].path
}
function buildFrameUrl(appUrl: string, path: string) {
export function buildFrameUrl(appUrl: string, path: string) {
if (typeof window === 'undefined') return path
const resolvedAppUrl = resolveBrowserAppUrl(appUrl)
@@ -43,7 +34,7 @@ function buildFrameUrl(appUrl: string, path: string) {
return appBase.toString()
}
const FRAME_HEIGHT = 'calc(100vh - 56px)'
export const FRAME_HEIGHT = 'calc(100vh - 56px)'
export default function WorkspaceFrame({ target }: { target: FrameId }) {
const { language, theme } = useMarketplacePreferences()
@@ -60,19 +51,6 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
setFramePath(getDefaultFramePath(target))
}, [target])
useEffect(() => {
if (target !== 'sign-in') return
const employeeToken = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('employee_token='))
if (employeeToken) {
window.location.replace('/dashboard')
}
}, [target])
useEffect(() => {
function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
+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,
})
})
})
+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:3000')
expect(resolveServerAppUrl('http://localhost:3000', null)).toBe('http://localhost:3000')
expect(resolveServerAppUrl('/relative', 'market.example.com')).toBe('/relative')
})
})
@@ -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')
})
})
+3 -3
View File
@@ -147,7 +147,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
{
heading: '1. Authentication Cookie',
paragraphs: [
'Name: employee_token',
'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.',
],
},
@@ -391,7 +391,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
{
heading: '1. Cookie dauthentification',
paragraphs: [
'Nom : employee_token',
'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.',
],
},
@@ -635,7 +635,7 @@ const footerContent: Record<MarketplaceLanguage, Record<FooterPageSlug, FooterPa
{
heading: '1. ملف تعريف الارتباط للمصادقة',
paragraphs: [
'الاسم: employee_token',
'الاسم: employee_session',
'يُعيَّن هذا الملف عند تسجيل دخولك إلى مساحة عمل RentalDriveGo. يخزّن رمزاً موقّعاً تشفيرياً يُثبت هويتك طوال مدة الجلسة. تنتهي صلاحيته تلقائياً بعد 8 ساعات ويُحذف عند تسجيل الخروج. هذا الملف ضروري بشكل مطلق — إذ لا يمكن الوصول إلى لوحة التحكم والمناطق المحمية بدونه.',
],
},
@@ -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')
})
})
+2 -2
View File
@@ -1,8 +1,8 @@
import { cookies } from 'next/headers'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from './i18n'
export function getMarketplaceLanguage(): MarketplaceLanguage {
const cookieStore = cookies()
export async function getMarketplaceLanguage(): Promise<MarketplaceLanguage> {
const cookieStore = await cookies()
const cookieValue =
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
+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)
})
})
@@ -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')
})
})
+1 -10
View File
@@ -3,16 +3,7 @@ export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
function readEmployeeToken() {
if (typeof window === 'undefined') return null
const localToken = window.localStorage.getItem('employee_token')
if (localToken) return localToken
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith('employee_token='))
?.split('=')[1] ?? null
return null
}
function decodeEmployeeId(token: string | null) {
+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),
}))
})
})
+2 -10
View File
@@ -46,26 +46,18 @@ export class RenterAuthError extends Error {
}
}
function getRenterToken() {
return window.localStorage.getItem('renter_token')
}
async function renterFetch<T>(path: string, init?: RequestInit): Promise<T> {
const token = getRenterToken()
if (!token) throw new RenterAuthError()
const res = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(init?.headers ?? {}),
},
})
const json = await res.json().catch(() => null)
if (res.status === 401) {
window.localStorage.removeItem('renter_token')
throw new RenterAuthError()
}
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
@@ -73,7 +65,7 @@ async function renterFetch<T>(path: string, init?: RequestInit): Promise<T> {
}
export function clearRenterSession() {
window.localStorage.removeItem('renter_token')
void fetch(`${API_BASE}/auth/renter/logout`, { method: 'POST', credentials: 'include' })
}
export function loadRenterProfile() {
+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')
})
})
+9
View File
@@ -4,6 +4,12 @@ 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'
}
@@ -18,6 +24,9 @@ function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
}
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