fix architecture and write new tests
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 d’authentification',
|
||||
paragraphs: [
|
||||
'Nom : employee_token',
|
||||
'Nom : employee_session',
|
||||
'Ce cookie est défini lorsque vous vous connectez à l’espace de travail RentalDriveGo. Il stocke un jeton cryptographiquement signé qui vérifie votre identité pour la durée de votre session. Il expire automatiquement après 8 heures et est supprimé lors de la déconnexion. Ce cookie est strictement nécessaire — sans lui, l’accès au tableau de bord et aux espaces protégés n’est pas possible.',
|
||||
],
|
||||
},
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user