dcd62f35ac
- Remove 'medium' theme from dashboard and marketplace; keep only light/dark - Marketplace: move public pages into (public)/ route group so header/footer persist across navigations without re-rendering (eliminates usePathname) - MarketplaceShell is now a pure context provider; chrome lives in (public)/layout.tsx which Next.js holds stable across navigations - WorkspaceFrame: remove history.replaceState and standalone-route logic - Docker API: bypass npm run dev to avoid node --env-file in containers - Add scripts/run-with-env-file.cjs for Node.js < 20.6 compatibility; update apps/api/package.json dev script to use it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { footerPageHref } from '@/lib/footerContent'
|
|
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
|
|
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
|
|
|
type Theme = 'light' | 'dark'
|
|
|
|
type Dictionary = {
|
|
marketplace: string
|
|
explore: string
|
|
signIn: string
|
|
ownerSignIn: string
|
|
language: string
|
|
theme: string
|
|
light: string
|
|
dark: string
|
|
preferences: string
|
|
}
|
|
|
|
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
|
en: {
|
|
marketplace: 'Home',
|
|
explore: 'Marketplace',
|
|
signIn: 'Sign in',
|
|
ownerSignIn: 'Create Agency Space',
|
|
language: 'Language',
|
|
theme: 'Theme',
|
|
light: 'Light',
|
|
dark: 'Dark',
|
|
preferences: 'Marketplace preferences',
|
|
},
|
|
fr: {
|
|
marketplace: 'Accueil',
|
|
explore: 'Marketplace',
|
|
signIn: 'Connexion',
|
|
ownerSignIn: "Créer un espace d'agence",
|
|
language: 'Langue',
|
|
theme: 'Mode',
|
|
light: 'Clair',
|
|
dark: 'Sombre',
|
|
preferences: 'Préférences marketplace',
|
|
},
|
|
ar: {
|
|
marketplace: 'الرئيسية',
|
|
explore: 'السوق',
|
|
signIn: 'تسجيل الدخول',
|
|
ownerSignIn: 'إنشاء مساحة الوكالة',
|
|
language: 'اللغة',
|
|
theme: 'الوضع',
|
|
light: 'فاتح',
|
|
dark: 'داكن',
|
|
preferences: 'تفضيلات السوق',
|
|
},
|
|
}
|
|
|
|
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
|
|
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
|
|
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
|
|
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
|
|
]
|
|
|
|
export function getFooterContent(language: MarketplaceLanguage): {
|
|
primary: Array<{ label: string; href?: string }>
|
|
secondary: Array<{ label: string; href?: string }>
|
|
localeLabel: string
|
|
rightsLabel: string
|
|
} {
|
|
switch (language) {
|
|
case 'fr':
|
|
return {
|
|
primary: [
|
|
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
|
|
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
|
|
{ label: 'Sécurité', href: footerPageHref.security },
|
|
{ label: 'Conformité', href: footerPageHref.compliance },
|
|
{ label: 'Politique de confidentialité', href: footerPageHref['privacy-policy'] },
|
|
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
|
|
],
|
|
secondary: [
|
|
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
|
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
|
|
{ label: 'Nos bureaux', href: footerPageHref['our-offices'] },
|
|
],
|
|
localeLabel: 'Europe (French)',
|
|
rightsLabel: 'Tous droits réservés.',
|
|
}
|
|
case 'ar':
|
|
return {
|
|
primary: [
|
|
{ label: 'من نحن', href: footerPageHref['about-us'] },
|
|
{ label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] },
|
|
{ label: 'الأمان', href: footerPageHref.security },
|
|
{ label: 'الامتثال', href: footerPageHref.compliance },
|
|
{ label: 'سياسة الخصوصية', href: footerPageHref['privacy-policy'] },
|
|
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
|
|
],
|
|
secondary: [
|
|
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
|
|
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
|
|
{ label: 'مكاتبنا', href: footerPageHref['our-offices'] },
|
|
],
|
|
localeLabel: 'North Africa (Arabic)',
|
|
rightsLabel: 'جميع الحقوق محفوظة.',
|
|
}
|
|
case 'en':
|
|
default:
|
|
return {
|
|
primary: [
|
|
{ label: 'About Us', href: footerPageHref['about-us'] },
|
|
{ label: 'Terms of Service', href: footerPageHref['terms-of-service'] },
|
|
{ label: 'Security', href: footerPageHref.security },
|
|
{ label: 'Compliance', href: footerPageHref.compliance },
|
|
{ label: 'Privacy Policy', href: footerPageHref['privacy-policy'] },
|
|
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
|
|
],
|
|
secondary: [
|
|
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
|
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
|
|
{ label: 'Our Offices', href: footerPageHref['our-offices'] },
|
|
],
|
|
localeLabel: 'Global (English)',
|
|
rightsLabel: 'All rights reserved.',
|
|
}
|
|
}
|
|
}
|
|
|
|
function detectBrowserLanguage(): string | null {
|
|
if (typeof navigator === 'undefined') return null
|
|
const langs = Array.from(navigator.languages ?? [navigator.language])
|
|
for (const lang of langs) {
|
|
const code = lang.split('-')[0].toLowerCase()
|
|
if (code === 'fr' || code === 'ar' || code === 'en') return code
|
|
}
|
|
return null
|
|
}
|
|
|
|
type PreferencesContextValue = {
|
|
language: MarketplaceLanguage
|
|
theme: Theme
|
|
dict: Dictionary
|
|
companyName: string | null
|
|
setLanguage: (language: MarketplaceLanguage) => void
|
|
setTheme: (theme: Theme) => void
|
|
}
|
|
|
|
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
|
|
|
export function useMarketplacePreferences() {
|
|
const context = useContext(PreferencesContext)
|
|
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
|
|
return context
|
|
}
|
|
|
|
export default function MarketplaceShell({
|
|
children,
|
|
initialLanguage = 'en',
|
|
}: {
|
|
children: React.ReactNode
|
|
initialLanguage?: MarketplaceLanguage
|
|
}) {
|
|
const router = useRouter()
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
|
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
|
|
const [theme, setThemeState] = useState<Theme>('light')
|
|
const [hydrated, setHydrated] = useState(false)
|
|
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
|
|
const [companyName, setCompanyName] = useState<string | null>(null)
|
|
|
|
function applyLanguage(nextLanguage: MarketplaceLanguage) {
|
|
if (nextLanguage === language) return
|
|
|
|
if (typeof window !== 'undefined') {
|
|
document.documentElement.lang = nextLanguage
|
|
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
|
|
try {
|
|
sessionStorage.setItem('marketplace-language', nextLanguage)
|
|
} catch {}
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
|
|
}
|
|
|
|
setLanguageState(nextLanguage)
|
|
}
|
|
|
|
function applyTheme(nextTheme: Theme) {
|
|
if (nextTheme === theme) return
|
|
|
|
if (typeof window !== 'undefined') {
|
|
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
|
|
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
|
|
document.body.dataset.theme = nextTheme
|
|
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
|
|
}
|
|
|
|
setThemeState(nextTheme)
|
|
}
|
|
|
|
async function syncCompanyBrand() {
|
|
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}` },
|
|
})
|
|
|
|
if (!response.ok) {
|
|
setCompanyName(null)
|
|
return
|
|
}
|
|
|
|
const json = await response.json()
|
|
const name = json?.data?.displayName ?? json?.displayName ?? null
|
|
setCompanyName(name)
|
|
} catch {
|
|
setCompanyName(null)
|
|
}
|
|
}
|
|
|
|
function readSessionLanguage(): MarketplaceLanguage | null {
|
|
try {
|
|
const val = sessionStorage.getItem('marketplace-language')
|
|
return isMarketplaceLanguage(val) ? val : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function syncScopedPreferencesForSession() {
|
|
const sessionLang = readSessionLanguage()
|
|
if (sessionLang) {
|
|
if (sessionLang !== language) setLanguageState(sessionLang)
|
|
} else {
|
|
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
|
|
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
|
|
setLanguageState(scopedLanguage)
|
|
}
|
|
}
|
|
|
|
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
|
|
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
|
|
setThemeState(scopedTheme)
|
|
} else {
|
|
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const sessionLang = readSessionLanguage()
|
|
if (sessionLang) {
|
|
setLanguageState(sessionLang)
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
|
|
} else {
|
|
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
|
|
if (isMarketplaceLanguage(storedLanguage)) {
|
|
setLanguageState(storedLanguage)
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
|
|
} else {
|
|
const detected = detectBrowserLanguage()
|
|
if (isMarketplaceLanguage(detected)) {
|
|
setLanguageState(detected)
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
|
|
}
|
|
}
|
|
}
|
|
|
|
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
|
|
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
setThemeState(storedTheme)
|
|
}
|
|
|
|
setHydrated(true)
|
|
void syncCompanyBrand()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
function handleWindowFocus() {
|
|
syncScopedPreferencesForSession()
|
|
void syncCompanyBrand()
|
|
}
|
|
|
|
function handleAuthMessage(event: MessageEvent) {
|
|
if (!event.data || typeof event.data !== 'object') return
|
|
|
|
const message = event.data as { type?: string }
|
|
|
|
if (message.type === 'rentaldrivego:employee-login' || message.type === 'rentaldrivego:employee-logout') {
|
|
syncScopedPreferencesForSession()
|
|
void syncCompanyBrand()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('focus', handleWindowFocus)
|
|
window.addEventListener('message', handleAuthMessage)
|
|
document.addEventListener('visibilitychange', handleWindowFocus)
|
|
|
|
return () => {
|
|
window.removeEventListener('focus', handleWindowFocus)
|
|
window.removeEventListener('message', handleAuthMessage)
|
|
document.removeEventListener('visibilitychange', handleWindowFocus)
|
|
}
|
|
}, [language, theme])
|
|
|
|
useEffect(() => {
|
|
document.documentElement.lang = language
|
|
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
|
|
|
if (!hydrated) return
|
|
|
|
try { sessionStorage.setItem('marketplace-language', language) } catch {}
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
|
|
|
|
if (previousLanguage.current !== language) {
|
|
router.refresh()
|
|
}
|
|
previousLanguage.current = language
|
|
}, [hydrated, language, router])
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
document.body.dataset.theme = theme
|
|
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
|
}, [theme])
|
|
|
|
const value = useMemo(
|
|
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
|
|
[language, theme, companyName],
|
|
)
|
|
|
|
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
|
|
}
|