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

This commit is contained in:
root
2026-08-31 19:50:27 -04:00
parent f00d2a8aea
commit cb8bf63218
23 changed files with 58 additions and 77 deletions
@@ -284,7 +284,7 @@ function createFormState(company: CompanyDetail): FormState {
publicCountry: company.brand?.publicCountry ?? '',
websiteUrl: company.brand?.websiteUrl ?? '',
whatsappNumber: company.brand?.whatsappNumber ?? '',
defaultLocale: company.brand?.defaultLocale ?? 'en',
defaultLocale: company.brand?.defaultLocale ?? 'ar',
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
},
+5 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { AdminI18nProvider } from '@/components/I18nProvider'
import './globals.css'
@@ -9,9 +10,11 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<html lang="ar" dir="rtl" className="dark" suppressHydrationWarning>
<head>
<script
<Script
id="admin-theme-bootstrap"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__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){}})();",
+3 -3
View File
@@ -109,10 +109,10 @@ type AdminI18nContext = {
const Context = createContext<AdminI18nContext | null>(null)
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')
// Skip the very first write so we don't overwrite a stored preference with
// the default 'en' value before the hydration read-effect has applied it.
// Skip the very first write so we don't overwrite a stored preference before
// the hydration read-effect has applied it.
const skipFirstLangWrite = useRef(true)
const skipFirstThemeWrite = useRef(true)
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import Script from 'next/script'
import CarplaceShell from '@/components/CarplaceShell'
import { getCarplaceMessages } from '@/lib/carplace/messages'
import { getCarplaceLanguage } from '@/lib/i18n.server'
@@ -36,8 +37,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
<head>
{/* Runs before hydration to prevent flash of wrong theme */}
<script
<Script
id="carplace-theme-bootstrap"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__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){}})();",
+2 -11
View File
@@ -118,15 +118,6 @@ export function getFooterContent(language: CarplaceLanguage): FooterContent {
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 = {
language: CarplaceLanguage
theme: Theme
@@ -145,7 +136,7 @@ export function useCarplacePreferences() {
export default function CarplaceShell({
children,
initialLanguage = 'en',
initialLanguage = 'ar',
initialTheme = 'dark',
}: {
children: React.ReactNode
@@ -180,7 +171,7 @@ export default function CarplaceShell({
useEffect(() => {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY)
const resolvedLanguage = isCarplaceLanguage(storedLanguage) ? storedLanguage : detectBrowserLanguage()
const resolvedLanguage = isCarplaceLanguage(storedLanguage) ? storedLanguage : 'ar'
if (resolvedLanguage) setLanguageState(resolvedLanguage)
const storedTheme = readScopedPreference(SHARED_THEME_KEY)
+3 -3
View File
@@ -22,9 +22,9 @@ describe('getCarplaceLanguage', () => {
await expect(getCarplaceLanguage()).resolves.toBe(language)
})
it('defaults to English when the shared cookie is absent or invalid', async () => {
await expect(getCarplaceLanguage()).resolves.toBe('en')
it('defaults to Arabic when the shared cookie is absent or invalid', async () => {
await expect(getCarplaceLanguage()).resolves.toBe('ar')
cookieValues.set(SHARED_LANGUAGE_COOKIE, 'es')
await expect(getCarplaceLanguage()).resolves.toBe('en')
await expect(getCarplaceLanguage()).resolves.toBe('ar')
})
})
+1 -1
View File
@@ -3,5 +3,5 @@ import { SHARED_LANGUAGE_COOKIE, isCarplaceLanguage, type CarplaceLanguage } fro
export async function getCarplaceLanguage(): Promise<CarplaceLanguage> {
const cookieValue = (await cookies()).get(SHARED_LANGUAGE_COOKIE)?.value
return isCarplaceLanguage(cookieValue) ? cookieValue : 'en'
return isCarplaceLanguage(cookieValue) ? cookieValue : 'ar'
}
+2 -2
View File
@@ -42,8 +42,8 @@ describe('Carplace language bootstrap', () => {
it.each([
['ar-MA,fr;q=0.8', 'ar'],
['de-DE,fr-FR;q=0.7', 'fr'],
['de-DE,es;q=0.9', 'en'],
['de-DE,fr-FR;q=0.7', 'ar'],
['de-DE,es;q=0.9', 'ar'],
])('resolves %s as %s', async (acceptLanguage, expected) => {
const { proxy } = await import('./proxy')
const response = proxy(request({ headers: { 'accept-language': acceptLanguage } }) as never) as any
+2 -10
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const DEFAULT_LANGUAGE: Language = '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'
}
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) {
if (request.headers.has('x-middleware-subrequest')) {
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
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 existingCookies = request.headers.get('cookie') ?? ''
const languageCookie = `${SHARED_LANGUAGE_COOKIE}=${language}`
+1 -1
View File
@@ -25,7 +25,7 @@ export const metadata: Metadata = {
}
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 }) {
@@ -86,7 +86,7 @@ describe("dashboard public auth pages", () => {
const text = collectText(page).join(" ");
const signInLink = findElement(
page,
(element) => element.props.href === "/en/dark/sign-in",
(element) => element.props.href === "/ar/dark/sign-in",
);
expect(text).toContain("Invitation accepted");
+4 -14
View File
@@ -1519,22 +1519,12 @@ const I18nContext = createContext<I18nContextValue | null>(null)
const DASHBOARD_LANGUAGE_KEY = 'dashboard-language'
function normalizeLanguage(value: string | null | undefined): DashboardLanguage {
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
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
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'ar'
}
export function DashboardI18nProvider({
children,
initialLanguage = 'en',
initialLanguage = 'ar',
}: {
children: React.ReactNode
initialLanguage?: DashboardLanguage
@@ -1580,8 +1570,8 @@ export function DashboardI18nProvider({
const next = normalizeLanguage(stored)
if (next !== language) setLanguageState(next)
} else {
const detected = detectBrowserLanguage()
if (detected && detected !== language) setLanguageState(detected)
const defaultLanguage = normalizeLanguage(null)
if (defaultLanguage !== language) setLanguageState(defaultLanguage)
}
}, [])
@@ -135,8 +135,8 @@ describe('DashboardAccessGuard route helpers', () => {
})
it('builds sign-in redirects with public dashboard return paths', () => {
expect(buildSignInRedirect('/reservations')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Freservations')
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
expect(buildSignInRedirect('/reservations')).toBe('/ar/dark/sign-in?redirect=%2Fdashboard%2Freservations')
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', () => {
@@ -21,8 +21,8 @@ describe('dashboard path normalization', () => {
})
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()).toBe('/en/dark/sign-in')
expect(buildHomepageSignInPath()).toBe('/ar/dark/sign-in')
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
const DASHBOARD_BASE_PATH = '/dashboard'
const DEFAULT_SIGN_IN_LOCALE = 'en'
const DEFAULT_SIGN_IN_LOCALE = 'ar'
const DEFAULT_SIGN_IN_THEME = 'dark'
export function toDashboardAppPath(path?: string | null): string {
+4 -4
View File
@@ -77,7 +77,7 @@ describe('dashboard proxy', () => {
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 () => {
@@ -85,7 +85,7 @@ describe('dashboard proxy', () => {
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 () => {
@@ -98,7 +98,7 @@ describe('dashboard proxy', () => {
},
}) 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 () => {
@@ -111,7 +111,7 @@ describe('dashboard proxy', () => {
},
}) 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 () => {
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NextRequest } from 'next/server'
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_BASE_PATH = '/dashboard'
const DEFAULT_SIGN_IN_LOCALE = 'en'
const DEFAULT_SIGN_IN_LOCALE = 'ar'
const DEFAULT_SIGN_IN_THEME = 'dark'
function toDashboardAppPath(pathname: string): string {
@@ -1,6 +1,6 @@
{
"version": "1.0",
"defaultLocale": "en",
"defaultLocale": "ar",
"locales": ["en", "fr", "ar"],
"rootDetection": true,
"routes": [
@@ -82,7 +82,7 @@
},
"hreflang": {
"values": ["en", "fr", "ar"],
"xDefaultLocale": "en",
"xDefaultLocale": "ar",
"selfCanonical": true
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ export type Locale = (typeof locales)[number];
export type Direction = 'ltr' | 'rtl';
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 localeCookie = 'hpc-locale';
export const localeStorageKey = 'hpc.locale';
+12 -9
View File
@@ -5,7 +5,6 @@ import {
isLocale,
localeCookie,
localeCookieMaxAgeSeconds,
localeFromAcceptLanguage,
localizedModePath,
routeIdFromAnyLocaleSlug,
routeIdFromSlug,
@@ -47,11 +46,8 @@ function resolveApiOrigin(): string | undefined {
return origins.size > 0 ? Array.from(origins).join(' ') : undefined;
}
function resolveRequestLocale(request: NextRequest) {
const cookieLocale = request.cookies.get(localeCookie)?.value;
return isLocale(cookieLocale)
? cookieLocale
: localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale;
function resolveRequestLocale() {
return defaultLocale;
}
function resolveRequestMode(request: NextRequest) {
@@ -69,6 +65,13 @@ function setLocaleCookie(response: NextResponse, locale: string): void {
secure: process.env.NODE_ENV === 'production',
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 {
@@ -98,7 +101,7 @@ export function proxy(request: NextRequest): NextResponse {
requestHeaders.set('Content-Security-Policy', csp);
if (request.nextUrl.pathname === '/') {
const locale = resolveRequestLocale(request);
const locale = resolveRequestLocale();
const mode = resolveRequestMode(request);
const destination = request.nextUrl.clone();
destination.pathname = `/${locale}/${mode}`;
@@ -110,11 +113,11 @@ export function proxy(request: NextRequest): NextResponse {
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 isFile = /\.\w+$/.test(request.nextUrl.pathname);
if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) {
const locale = resolveRequestLocale(request);
const locale = resolveRequestLocale();
const mode = resolveRequestMode(request);
const destination = request.nextUrl.clone();
destination.pathname = `/${locale}/${mode}${request.nextUrl.pathname}`;
+1 -1
View File
@@ -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({
extraHTTPHeaders: { 'Accept-Language': 'ar;q=1,en;q=0.8' },
});
@@ -16,7 +16,7 @@ describe('localization foundations', () => {
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('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', () => {
@@ -45,7 +45,7 @@ describe('localization foundations', () => {
const links = hreflangMap(new URL('https://approved.test'), 'home');
expect(links.en).toBe('https://approved.test/en/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', () => {
+1 -1
View File
@@ -76,7 +76,7 @@ describe('homepage proxy app boundaries', () => {
expect(response).toMatchObject({
kind: 'redirect',
url: 'https://rentaldrivego.ma/fr/dark',
url: 'https://rentaldrivego.ma/ar/dark',
});
});