update arabic to be default
Build & Push / Pipeline Tests (push) Successful in 1m56s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Successful in 3m39s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 52s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 52s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m8s
Build & Push / Pipeline Tests (push) Successful in 1m56s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Successful in 3m39s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 52s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 52s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m8s
This commit is contained in:
@@ -284,7 +284,7 @@ function createFormState(company: CompanyDetail): FormState {
|
|||||||
publicCountry: company.brand?.publicCountry ?? '',
|
publicCountry: company.brand?.publicCountry ?? '',
|
||||||
websiteUrl: company.brand?.websiteUrl ?? '',
|
websiteUrl: company.brand?.websiteUrl ?? '',
|
||||||
whatsappNumber: company.brand?.whatsappNumber ?? '',
|
whatsappNumber: company.brand?.whatsappNumber ?? '',
|
||||||
defaultLocale: company.brand?.defaultLocale ?? 'en',
|
defaultLocale: company.brand?.defaultLocale ?? 'ar',
|
||||||
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
|
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
|
||||||
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
|
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import Script from 'next/script'
|
||||||
import { AdminI18nProvider } from '@/components/I18nProvider'
|
import { AdminI18nProvider } from '@/components/I18nProvider'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
@@ -9,9 +10,11 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark" suppressHydrationWarning>
|
<html lang="ar" dir="rtl" className="dark" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<Script
|
||||||
|
id="admin-theme-bootstrap"
|
||||||
|
strategy="beforeInteractive"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__html:
|
||||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ type AdminI18nContext = {
|
|||||||
const Context = createContext<AdminI18nContext | null>(null)
|
const Context = createContext<AdminI18nContext | null>(null)
|
||||||
|
|
||||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
const [language, setLanguage] = useState<AdminLanguage>('ar')
|
||||||
const [theme, setTheme] = useState<AdminTheme>('dark')
|
const [theme, setTheme] = useState<AdminTheme>('dark')
|
||||||
// Skip the very first write so we don't overwrite a stored preference with
|
// Skip the very first write so we don't overwrite a stored preference before
|
||||||
// the default 'en' value before the hydration read-effect has applied it.
|
// the hydration read-effect has applied it.
|
||||||
const skipFirstLangWrite = useRef(true)
|
const skipFirstLangWrite = useRef(true)
|
||||||
const skipFirstThemeWrite = useRef(true)
|
const skipFirstThemeWrite = useRef(true)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
|
import Script from 'next/script'
|
||||||
import CarplaceShell from '@/components/CarplaceShell'
|
import CarplaceShell from '@/components/CarplaceShell'
|
||||||
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
import { getCarplaceMessages } from '@/lib/carplace/messages'
|
||||||
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
import { getCarplaceLanguage } from '@/lib/i18n.server'
|
||||||
@@ -36,8 +37,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
return (
|
return (
|
||||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
<Script
|
||||||
<script
|
id="carplace-theme-bootstrap"
|
||||||
|
strategy="beforeInteractive"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__html:
|
||||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):localStorage.getItem('rentaldrivego-theme');if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):localStorage.getItem('rentaldrivego-theme');if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||||
|
|||||||
@@ -118,15 +118,6 @@ export function getFooterContent(language: CarplaceLanguage): FooterContent {
|
|||||||
return footerLabels[language]
|
return footerLabels[language]
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectBrowserLanguage(): CarplaceLanguage | null {
|
|
||||||
if (typeof navigator === 'undefined') return null
|
|
||||||
for (const language of Array.from(navigator.languages ?? [navigator.language])) {
|
|
||||||
const code = language.split('-')[0].toLowerCase()
|
|
||||||
if (isCarplaceLanguage(code)) return code
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreferencesContextValue = {
|
type PreferencesContextValue = {
|
||||||
language: CarplaceLanguage
|
language: CarplaceLanguage
|
||||||
theme: Theme
|
theme: Theme
|
||||||
@@ -145,7 +136,7 @@ export function useCarplacePreferences() {
|
|||||||
|
|
||||||
export default function CarplaceShell({
|
export default function CarplaceShell({
|
||||||
children,
|
children,
|
||||||
initialLanguage = 'en',
|
initialLanguage = 'ar',
|
||||||
initialTheme = 'dark',
|
initialTheme = 'dark',
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -180,7 +171,7 @@ export default function CarplaceShell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY)
|
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY)
|
||||||
const resolvedLanguage = isCarplaceLanguage(storedLanguage) ? storedLanguage : detectBrowserLanguage()
|
const resolvedLanguage = isCarplaceLanguage(storedLanguage) ? storedLanguage : 'ar'
|
||||||
if (resolvedLanguage) setLanguageState(resolvedLanguage)
|
if (resolvedLanguage) setLanguageState(resolvedLanguage)
|
||||||
|
|
||||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY)
|
const storedTheme = readScopedPreference(SHARED_THEME_KEY)
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ describe('getCarplaceLanguage', () => {
|
|||||||
await expect(getCarplaceLanguage()).resolves.toBe(language)
|
await expect(getCarplaceLanguage()).resolves.toBe(language)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults to English when the shared cookie is absent or invalid', async () => {
|
it('defaults to Arabic when the shared cookie is absent or invalid', async () => {
|
||||||
await expect(getCarplaceLanguage()).resolves.toBe('en')
|
await expect(getCarplaceLanguage()).resolves.toBe('ar')
|
||||||
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
|
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
|
||||||
await expect(getCarplaceLanguage()).resolves.toBe('en')
|
await expect(getCarplaceLanguage()).resolves.toBe('ar')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import { SHARED_LANGUAGE_COOKIE, isCarplaceLanguage, type CarplaceLanguage } fro
|
|||||||
|
|
||||||
export async function getCarplaceLanguage(): Promise<CarplaceLanguage> {
|
export async function getCarplaceLanguage(): Promise<CarplaceLanguage> {
|
||||||
const cookieValue = (await cookies()).get(SHARED_LANGUAGE_COOKIE)?.value
|
const cookieValue = (await cookies()).get(SHARED_LANGUAGE_COOKIE)?.value
|
||||||
return isCarplaceLanguage(cookieValue) ? cookieValue : 'en'
|
return isCarplaceLanguage(cookieValue) ? cookieValue : 'ar'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ describe('Carplace language bootstrap', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['ar-MA,fr;q=0.8', 'ar'],
|
['ar-MA,fr;q=0.8', 'ar'],
|
||||||
['de-DE,fr-FR;q=0.7', 'fr'],
|
['de-DE,fr-FR;q=0.7', 'ar'],
|
||||||
['de-DE,es;q=0.9', 'en'],
|
['de-DE,es;q=0.9', 'ar'],
|
||||||
])('resolves %s as %s', async (acceptLanguage, expected) => {
|
])('resolves %s as %s', async (acceptLanguage, expected) => {
|
||||||
const { proxy } = await import('./proxy')
|
const { proxy } = await import('./proxy')
|
||||||
const response = proxy(request({ headers: { 'accept-language': acceptLanguage } }) as never) as any
|
const response = proxy(request({ headers: { 'accept-language': acceptLanguage } }) as never) as any
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
|
|
||||||
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||||
|
const DEFAULT_LANGUAGE: Language = 'ar'
|
||||||
|
|
||||||
type Language = 'en' | 'fr' | 'ar'
|
type Language = 'en' | 'fr' | 'ar'
|
||||||
|
|
||||||
@@ -9,15 +10,6 @@ function isValidLanguage(value: string | null | undefined): value is Language {
|
|||||||
return value === 'en' || value === 'fr' || value === 'ar'
|
return value === 'en' || value === 'fr' || value === 'ar'
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectFromAcceptLanguage(header: string | null): Language {
|
|
||||||
if (!header) return 'en'
|
|
||||||
for (const entry of header.split(',')) {
|
|
||||||
const code = entry.split(';')[0].trim().split('-')[0].toLowerCase()
|
|
||||||
if (isValidLanguage(code)) return code
|
|
||||||
}
|
|
||||||
return 'en'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
if (request.headers.has('x-middleware-subrequest')) {
|
if (request.headers.has('x-middleware-subrequest')) {
|
||||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||||
@@ -26,7 +18,7 @@ export function proxy(request: NextRequest) {
|
|||||||
const currentLanguage = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
|
const currentLanguage = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
|
||||||
if (isValidLanguage(currentLanguage)) return NextResponse.next()
|
if (isValidLanguage(currentLanguage)) return NextResponse.next()
|
||||||
|
|
||||||
const language = detectFromAcceptLanguage(request.headers.get('accept-language'))
|
const language = DEFAULT_LANGUAGE
|
||||||
const requestHeaders = new Headers(request.headers)
|
const requestHeaders = new Headers(request.headers)
|
||||||
const existingCookies = request.headers.get('cookie') ?? ''
|
const existingCookies = request.headers.get('cookie') ?? ''
|
||||||
const languageCookie = `${SHARED_LANGUAGE_COOKIE}=${language}`
|
const languageCookie = `${SHARED_LANGUAGE_COOKIE}=${language}`
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const metadata: Metadata = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
|
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
|
||||||
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
|
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'ar'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe("dashboard public auth pages", () => {
|
|||||||
const text = collectText(page).join(" ");
|
const text = collectText(page).join(" ");
|
||||||
const signInLink = findElement(
|
const signInLink = findElement(
|
||||||
page,
|
page,
|
||||||
(element) => element.props.href === "/en/dark/sign-in",
|
(element) => element.props.href === "/ar/dark/sign-in",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(text).toContain("Invitation accepted");
|
expect(text).toContain("Invitation accepted");
|
||||||
|
|||||||
@@ -1519,22 +1519,12 @@ const I18nContext = createContext<I18nContextValue | null>(null)
|
|||||||
const DASHBOARD_LANGUAGE_KEY = 'dashboard-language'
|
const DASHBOARD_LANGUAGE_KEY = 'dashboard-language'
|
||||||
|
|
||||||
function normalizeLanguage(value: string | null | undefined): DashboardLanguage {
|
function normalizeLanguage(value: string | null | undefined): DashboardLanguage {
|
||||||
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
|
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'ar'
|
||||||
}
|
|
||||||
|
|
||||||
function detectBrowserLanguage(): DashboardLanguage | null {
|
|
||||||
if (typeof navigator === 'undefined') return null
|
|
||||||
const langs = Array.from(navigator.languages ?? [navigator.language])
|
|
||||||
for (const lang of langs) {
|
|
||||||
const code = lang.split('-')[0].toLowerCase()
|
|
||||||
if (code === 'fr' || code === 'ar' || code === 'en') return code as DashboardLanguage
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardI18nProvider({
|
export function DashboardI18nProvider({
|
||||||
children,
|
children,
|
||||||
initialLanguage = 'en',
|
initialLanguage = 'ar',
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
initialLanguage?: DashboardLanguage
|
initialLanguage?: DashboardLanguage
|
||||||
@@ -1580,8 +1570,8 @@ export function DashboardI18nProvider({
|
|||||||
const next = normalizeLanguage(stored)
|
const next = normalizeLanguage(stored)
|
||||||
if (next !== language) setLanguageState(next)
|
if (next !== language) setLanguageState(next)
|
||||||
} else {
|
} else {
|
||||||
const detected = detectBrowserLanguage()
|
const defaultLanguage = normalizeLanguage(null)
|
||||||
if (detected && detected !== language) setLanguageState(detected)
|
if (defaultLanguage !== language) setLanguageState(defaultLanguage)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ describe('DashboardAccessGuard route helpers', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('builds sign-in redirects with public dashboard return paths', () => {
|
it('builds sign-in redirects with public dashboard return paths', () => {
|
||||||
expect(buildSignInRedirect('/reservations')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Freservations')
|
expect(buildSignInRedirect('/reservations')).toBe('/ar/dark/sign-in?redirect=%2Fdashboard%2Freservations')
|
||||||
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
|
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/ar/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('treats subscription as an owner-only recovery route independent of menu registration', () => {
|
it('treats subscription as an owner-only recovery route independent of menu registration', () => {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ describe('dashboard path normalization', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('builds the canonical homepage sign-in URL with a dashboard return path', () => {
|
it('builds the canonical homepage sign-in URL with a dashboard return path', () => {
|
||||||
expect(buildHomepageSignInPath('/reservations')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Freservations')
|
expect(buildHomepageSignInPath('/reservations')).toBe('/ar/dark/sign-in?redirect=%2Fdashboard%2Freservations')
|
||||||
expect(buildHomepageSignInPath('/dashboard/fleet', { locale: 'fr', theme: 'dark' })).toBe('/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
|
expect(buildHomepageSignInPath('/dashboard/fleet', { locale: 'fr', theme: 'dark' })).toBe('/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
|
||||||
expect(buildHomepageSignInPath()).toBe('/en/dark/sign-in')
|
expect(buildHomepageSignInPath()).toBe('/ar/dark/sign-in')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||||
const DEFAULT_SIGN_IN_LOCALE = 'en'
|
const DEFAULT_SIGN_IN_LOCALE = 'ar'
|
||||||
const DEFAULT_SIGN_IN_THEME = 'dark'
|
const DEFAULT_SIGN_IN_THEME = 'dark'
|
||||||
|
|
||||||
export function toDashboardAppPath(path?: string | null): string {
|
export function toDashboardAppPath(path?: string | null): string {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe('dashboard proxy', () => {
|
|||||||
|
|
||||||
const response = proxy(request('http://dashboard:3001/dashboard/team') as never)
|
const response = proxy(request('http://dashboard:3001/dashboard/team') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Fteam' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/ar/dark/sign-in?redirect=%2Fdashboard%2Fteam' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects unprefixed internal app paths with a public dashboard return path', async () => {
|
it('redirects unprefixed internal app paths with a public dashboard return path', async () => {
|
||||||
@@ -85,7 +85,7 @@ describe('dashboard proxy', () => {
|
|||||||
|
|
||||||
const response = proxy(request('http://dashboard:3001/reservations') as never)
|
const response = proxy(request('http://dashboard:3001/reservations') as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Freservations' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/ar/dark/sign-in?redirect=%2Fdashboard%2Freservations' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
||||||
@@ -98,7 +98,7 @@ describe('dashboard proxy', () => {
|
|||||||
},
|
},
|
||||||
}) as never)
|
}) as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/dark/sign-in?redirect=%2Fdashboard%2Fbilling' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/ar/dark/sign-in?redirect=%2Fdashboard%2Fbilling' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
|
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
|
||||||
@@ -111,7 +111,7 @@ describe('dashboard proxy', () => {
|
|||||||
},
|
},
|
||||||
}) as never)
|
}) as never)
|
||||||
|
|
||||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/dark/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/ar/dark/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects legacy dashboard sign-in requests to the localized homepage sign-in route', async () => {
|
it('redirects legacy dashboard sign-in requests to the localized homepage sign-in route', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { NextRequest } from 'next/server'
|
|||||||
const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000'
|
const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000'
|
||||||
const DASHBOARD_PUBLIC_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? `${WEBSITE_URL.replace(/\/$/, '')}/dashboard`
|
const DASHBOARD_PUBLIC_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? `${WEBSITE_URL.replace(/\/$/, '')}/dashboard`
|
||||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||||
const DEFAULT_SIGN_IN_LOCALE = 'en'
|
const DEFAULT_SIGN_IN_LOCALE = 'ar'
|
||||||
const DEFAULT_SIGN_IN_THEME = 'dark'
|
const DEFAULT_SIGN_IN_THEME = 'dark'
|
||||||
|
|
||||||
function toDashboardAppPath(pathname: string): string {
|
function toDashboardAppPath(pathname: string): string {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"defaultLocale": "en",
|
"defaultLocale": "ar",
|
||||||
"locales": ["en", "fr", "ar"],
|
"locales": ["en", "fr", "ar"],
|
||||||
"rootDetection": true,
|
"rootDetection": true,
|
||||||
"routes": [
|
"routes": [
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
},
|
},
|
||||||
"hreflang": {
|
"hreflang": {
|
||||||
"values": ["en", "fr", "ar"],
|
"values": ["en", "fr", "ar"],
|
||||||
"xDefaultLocale": "en",
|
"xDefaultLocale": "ar",
|
||||||
"selfCanonical": true
|
"selfCanonical": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type Locale = (typeof locales)[number];
|
|||||||
export type Direction = 'ltr' | 'rtl';
|
export type Direction = 'ltr' | 'rtl';
|
||||||
export type RouteId = 'home' | 'sign-in' | 'forgot-password' | 'reset-password' | 'privacy' | 'terms' | 'accessibility';
|
export type RouteId = 'home' | 'sign-in' | 'forgot-password' | 'reset-password' | 'privacy' | 'terms' | 'accessibility';
|
||||||
|
|
||||||
export const defaultLocale: Locale = 'en';
|
export const defaultLocale: Locale = 'ar';
|
||||||
export const defaultMode: ThemePreference = 'dark';
|
export const defaultMode: ThemePreference = 'dark';
|
||||||
export const localeCookie = 'hpc-locale';
|
export const localeCookie = 'hpc-locale';
|
||||||
export const localeStorageKey = 'hpc.locale';
|
export const localeStorageKey = 'hpc.locale';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
isLocale,
|
isLocale,
|
||||||
localeCookie,
|
localeCookie,
|
||||||
localeCookieMaxAgeSeconds,
|
localeCookieMaxAgeSeconds,
|
||||||
localeFromAcceptLanguage,
|
|
||||||
localizedModePath,
|
localizedModePath,
|
||||||
routeIdFromAnyLocaleSlug,
|
routeIdFromAnyLocaleSlug,
|
||||||
routeIdFromSlug,
|
routeIdFromSlug,
|
||||||
@@ -47,11 +46,8 @@ function resolveApiOrigin(): string | undefined {
|
|||||||
return origins.size > 0 ? Array.from(origins).join(' ') : undefined;
|
return origins.size > 0 ? Array.from(origins).join(' ') : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequestLocale(request: NextRequest) {
|
function resolveRequestLocale() {
|
||||||
const cookieLocale = request.cookies.get(localeCookie)?.value;
|
return defaultLocale;
|
||||||
return isLocale(cookieLocale)
|
|
||||||
? cookieLocale
|
|
||||||
: localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequestMode(request: NextRequest) {
|
function resolveRequestMode(request: NextRequest) {
|
||||||
@@ -69,6 +65,13 @@ function setLocaleCookie(response: NextResponse, locale: string): void {
|
|||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
});
|
});
|
||||||
|
response.cookies.set(dashboardLanguageCookie, locale, {
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: localeCookieMaxAgeSeconds,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setModeCookie(response: NextResponse, mode: string): void {
|
function setModeCookie(response: NextResponse, mode: string): void {
|
||||||
@@ -98,7 +101,7 @@ export function proxy(request: NextRequest): NextResponse {
|
|||||||
requestHeaders.set('Content-Security-Policy', csp);
|
requestHeaders.set('Content-Security-Policy', csp);
|
||||||
|
|
||||||
if (request.nextUrl.pathname === '/') {
|
if (request.nextUrl.pathname === '/') {
|
||||||
const locale = resolveRequestLocale(request);
|
const locale = resolveRequestLocale();
|
||||||
const mode = resolveRequestMode(request);
|
const mode = resolveRequestMode(request);
|
||||||
const destination = request.nextUrl.clone();
|
const destination = request.nextUrl.clone();
|
||||||
destination.pathname = `/${locale}/${mode}`;
|
destination.pathname = `/${locale}/${mode}`;
|
||||||
@@ -110,11 +113,11 @@ export function proxy(request: NextRequest): NextResponse {
|
|||||||
|
|
||||||
const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean);
|
const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
// Redirect locale-less paths (e.g. /sign-in -> /en/dark/sign-in), but not proxied app paths or files.
|
// Redirect locale-less paths (e.g. /sign-in -> /ar/dark/sign-in), but not proxied app paths or files.
|
||||||
const firstSegment = request.nextUrl.pathname.split('/')[1];
|
const firstSegment = request.nextUrl.pathname.split('/')[1];
|
||||||
const isFile = /\.\w+$/.test(request.nextUrl.pathname);
|
const isFile = /\.\w+$/.test(request.nextUrl.pathname);
|
||||||
if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) {
|
if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) {
|
||||||
const locale = resolveRequestLocale(request);
|
const locale = resolveRequestLocale();
|
||||||
const mode = resolveRequestMode(request);
|
const mode = resolveRequestMode(request);
|
||||||
const destination = request.nextUrl.clone();
|
const destination = request.nextUrl.clone();
|
||||||
destination.pathname = `/${locale}/${mode}${request.nextUrl.pathname}`;
|
destination.pathname = `/${locale}/${mode}${request.nextUrl.pathname}`;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ for (const scenario of [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('root locale detection honors Accept-Language', async ({ browser }) => {
|
test('root locale defaults to Arabic', async ({ browser }) => {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
extraHTTPHeaders: { 'Accept-Language': 'ar;q=1,en;q=0.8' },
|
extraHTTPHeaders: { 'Accept-Language': 'ar;q=1,en;q=0.8' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('localization foundations', () => {
|
|||||||
it('selects a supported locale by quality', () => {
|
it('selects a supported locale by quality', () => {
|
||||||
expect(localeFromAcceptLanguage('de-DE;q=0.9, ar;q=0.8, fr;q=0.7')).toBe('ar');
|
expect(localeFromAcceptLanguage('de-DE;q=0.9, ar;q=0.8, fr;q=0.7')).toBe('ar');
|
||||||
expect(localeFromAcceptLanguage('fr-CA, en;q=0.8')).toBe('fr');
|
expect(localeFromAcceptLanguage('fr-CA, en;q=0.8')).toBe('fr');
|
||||||
expect(localeFromAcceptLanguage(null)).toBe('en');
|
expect(localeFromAcceptLanguage(null)).toBe('ar');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates locale direction and route lookup', () => {
|
it('validates locale direction and route lookup', () => {
|
||||||
@@ -45,7 +45,7 @@ describe('localization foundations', () => {
|
|||||||
const links = hreflangMap(new URL('https://approved.test'), 'home');
|
const links = hreflangMap(new URL('https://approved.test'), 'home');
|
||||||
expect(links.en).toBe('https://approved.test/en/dark');
|
expect(links.en).toBe('https://approved.test/en/dark');
|
||||||
expect(links.ar).toBe('https://approved.test/ar/dark');
|
expect(links.ar).toBe('https://approved.test/ar/dark');
|
||||||
expect(links['x-default']).toBe('https://approved.test/en/dark');
|
expect(links['x-default']).toBe('https://approved.test/ar/dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves only approved campaign query values and valid hashes', () => {
|
it('preserves only approved campaign query values and valid hashes', () => {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('homepage proxy app boundaries', () => {
|
|||||||
|
|
||||||
expect(response).toMatchObject({
|
expect(response).toMatchObject({
|
||||||
kind: 'redirect',
|
kind: 'redirect',
|
||||||
url: 'https://rentaldrivego.ma/fr/dark',
|
url: 'https://rentaldrivego.ma/ar/dark',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user