d7fb7b7a7b
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
|
export type AdminTheme = 'light' | 'dark'
|
|
|
|
type AdminDictionary = {
|
|
nav: Record<string, string>
|
|
logout: string
|
|
language: string
|
|
theme: string
|
|
light: string
|
|
dark: string
|
|
overview: string
|
|
admin: string
|
|
platform: string
|
|
loading: string
|
|
}
|
|
|
|
const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
|
en: {
|
|
nav: {
|
|
overview: 'Overview',
|
|
companies: 'Companies',
|
|
siteConfig: 'Site Config',
|
|
renters: 'Renters',
|
|
auditLogs: 'Audit Logs',
|
|
adminUsers: 'Admin Users',
|
|
billing: 'Billing',
|
|
pricing: 'Pricing',
|
|
notifications: 'Notifications',
|
|
menuManagement: 'Menu Management',
|
|
},
|
|
logout: 'Logout',
|
|
language: 'Language',
|
|
theme: 'Theme',
|
|
light: 'Light',
|
|
dark: 'Dark',
|
|
overview: 'Platform overview',
|
|
admin: 'Admin',
|
|
platform: 'Platform',
|
|
loading: 'Loading',
|
|
},
|
|
fr: {
|
|
nav: {
|
|
overview: "Vue d'ensemble",
|
|
companies: 'Entreprises',
|
|
siteConfig: 'Config site',
|
|
renters: 'Locataires',
|
|
auditLogs: "Journaux d'audit",
|
|
adminUsers: 'Utilisateurs admin',
|
|
billing: 'Facturation',
|
|
pricing: 'Tarification',
|
|
notifications: 'Notifications',
|
|
menuManagement: 'Gestion du menu',
|
|
},
|
|
logout: 'Déconnexion',
|
|
language: 'Langue',
|
|
theme: 'Mode',
|
|
light: 'Clair',
|
|
dark: 'Sombre',
|
|
overview: 'Vue plateforme',
|
|
admin: 'Admin',
|
|
platform: 'Plateforme',
|
|
loading: 'Chargement',
|
|
},
|
|
ar: {
|
|
nav: {
|
|
overview: 'نظرة عامة',
|
|
companies: 'الشركات',
|
|
siteConfig: 'إعدادات الموقع',
|
|
renters: 'المستأجرون',
|
|
auditLogs: 'سجلات التدقيق',
|
|
adminUsers: 'مستخدمو الإدارة',
|
|
billing: 'الفوترة',
|
|
pricing: 'الأسعار',
|
|
notifications: 'الإشعارات',
|
|
menuManagement: 'إدارة القوائم',
|
|
},
|
|
logout: 'تسجيل الخروج',
|
|
language: 'اللغة',
|
|
theme: 'الوضع',
|
|
light: 'فاتح',
|
|
dark: 'داكن',
|
|
overview: 'نظرة عامة على المنصة',
|
|
admin: 'الإدارة',
|
|
platform: 'المنصة',
|
|
loading: 'جارٍ التحميل',
|
|
},
|
|
}
|
|
|
|
const languageMeta = {
|
|
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
|
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
|
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
|
} as const
|
|
|
|
const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
|
|
|
type AdminI18nContext = {
|
|
language: AdminLanguage
|
|
setLanguage: (value: AdminLanguage) => void
|
|
theme: AdminTheme
|
|
setTheme: (value: AdminTheme) => void
|
|
dict: AdminDictionary
|
|
}
|
|
|
|
const Context = createContext<AdminI18nContext | null>(null)
|
|
|
|
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
|
const [language, setLanguage] = useState<AdminLanguage>('en')
|
|
const [theme, setTheme] = useState<AdminTheme>('light')
|
|
// 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.
|
|
const skipFirstLangWrite = useRef(true)
|
|
|
|
useEffect(() => {
|
|
const stored = window.localStorage.getItem('admin-language')
|
|
const storedTheme = window.localStorage.getItem(SHARED_THEME_KEY) ?? window.localStorage.getItem('admin-theme')
|
|
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
|
setLanguage(stored)
|
|
}
|
|
|
|
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
setTheme(storedTheme)
|
|
return
|
|
}
|
|
|
|
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
setTheme('light')
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
document.documentElement.lang = language
|
|
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
|
if (skipFirstLangWrite.current) {
|
|
skipFirstLangWrite.current = false
|
|
return
|
|
}
|
|
window.localStorage.setItem('admin-language', language)
|
|
}, [language])
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.remove('light', 'dark')
|
|
document.documentElement.classList.add(theme)
|
|
document.documentElement.style.colorScheme = theme
|
|
document.body.dataset.theme = theme
|
|
document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
|
|
window.localStorage.setItem(SHARED_THEME_KEY, theme)
|
|
window.localStorage.setItem('admin-theme', theme)
|
|
}, [theme])
|
|
|
|
const value = useMemo(
|
|
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
|
|
[language, theme],
|
|
)
|
|
return <Context.Provider value={value}>{children}</Context.Provider>
|
|
}
|
|
|
|
export function useAdminI18n() {
|
|
const context = useContext(Context)
|
|
if (!context) throw new Error('useAdminI18n must be used within AdminI18nProvider')
|
|
return context
|
|
}
|
|
|
|
export function AdminLanguageSwitcher() {
|
|
const { language, setLanguage } = useAdminI18n()
|
|
const [open, setOpen] = useState(false)
|
|
const [embedded, setEmbedded] = useState(false)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
setEmbedded(window.self !== window.top)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function onMouseDown(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', onMouseDown)
|
|
return () => document.removeEventListener('mousedown', onMouseDown)
|
|
}, [open])
|
|
|
|
if (embedded) return null
|
|
|
|
const current = languageMeta[language]
|
|
|
|
return (
|
|
<div ref={ref} className="relative flex-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<span aria-hidden="true">{current.flag}</span>
|
|
<span>{current.shortLabel}</span>
|
|
</span>
|
|
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
|
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
|
const meta = languageMeta[value]
|
|
const active = value === language
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => { setLanguage(value); setOpen(false) }}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
|
active
|
|
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
|
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
|
}`}
|
|
>
|
|
<span aria-hidden="true">{meta.flag}</span>
|
|
<span>{meta.shortLabel}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function AdminThemeSwitcher() {
|
|
const { theme, setTheme, dict } = useAdminI18n()
|
|
const [open, setOpen] = useState(false)
|
|
const [embedded, setEmbedded] = useState(false)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
setEmbedded(window.self !== window.top)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function onMouseDown(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', onMouseDown)
|
|
return () => document.removeEventListener('mousedown', onMouseDown)
|
|
}, [open])
|
|
|
|
if (embedded) return null
|
|
|
|
return (
|
|
<div ref={ref} className="relative flex-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
{theme === 'light' ? (
|
|
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
|
</svg>
|
|
)}
|
|
<span>{theme === 'light' ? dict.light : dict.dark}</span>
|
|
</span>
|
|
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
|
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
|
const active = value === theme
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => { setTheme(value); setOpen(false) }}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
|
active
|
|
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
|
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
|
}`}
|
|
>
|
|
{value === 'light' ? (
|
|
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
|
</svg>
|
|
)}
|
|
<span className={active ? 'text-inherit' : ''}>{value === 'light' ? dict.light : dict.dark}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|