Files
carmanagement/apps/marketplace/src/components/MarketplaceShell.tsx
T
2026-05-15 15:44:31 +00:00

498 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { ChevronDown, Globe } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'medium' | 'dark'
type Dictionary = {
marketplace: string
explore: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
medium: string
dark: string
preferences: string
}
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
en: {
marketplace: 'Home',
explore: 'Marketplace',
signIn: 'Sign in',
ownerSignIn: 'Create account',
language: 'Language',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
preferences: 'Marketplace preferences',
},
fr: {
marketplace: 'Accueil',
explore: 'Marketplace',
signIn: 'Connexion',
ownerSignIn: 'Créer un compte',
language: 'Langue',
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
preferences: 'Préférences marketplace',
},
ar: {
marketplace: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء حساب',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
medium: 'متوسط',
dark: 'داكن',
preferences: 'تفضيلات السوق',
},
}
type PreferencesContextValue = {
language: MarketplaceLanguage
theme: Theme
dict: Dictionary
setLanguage: (language: MarketplaceLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
type FooterItem = {
label: string
href?: string
}
const localeOptions: Array<{ value: MarketplaceLanguage; label: string }> = [
{ value: 'en', label: 'Global (English)' },
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
{ value: 'fr', label: 'Europ (French)' },
]
function getFooterContent(language: MarketplaceLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
{ label: 'Conditions dutilisation', 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: 'Europ (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: 'NorthAfrica (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 SegmentedControl<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: { value: T; label: string }[]
onChange: (value: T) => void
}) {
return (
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
{label}
</span>
<div className="flex items-center gap-1">
{options.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
}`}
>
{option.label}
</button>
)
})}
</div>
</div>
)
}
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 }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002')
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguage] = useState<MarketplaceLanguage>('en')
const [theme, setTheme] = useState<Theme>('light')
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>('en')
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [companyName, setCompanyName] = useState<string | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
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 syncScopedPreferencesForSession() {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguage(scopedLanguage)
} else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
}
if ((scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') && scopedTheme !== theme) {
setTheme(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}
useEffect(() => {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (isMarketplaceLanguage(storedLanguage)) {
setLanguage(storedLanguage)
}
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
setTheme(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'
document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
document.body.dataset.theme = theme
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
document.cookie = `${MARKETPLACE_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
if (hydrated && previousLanguage.current !== language) {
router.refresh()
}
previousLanguage.current = language
}, [hydrated, language, router, theme])
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
const dict = dictionaries[language]
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const isRenterApp = pathname.startsWith('/renter/')
const isEmbeddedWorkspace = pathname === '/branded-website' || isRenterApp
const hideHeader = isRenterApp
const value = useMemo(
() => ({ language, theme, dict, setLanguage, setTheme }),
[dict, language, theme],
)
return (
<PreferencesContext.Provider value={value}>
<div className="min-h-screen bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100">
{!hideHeader ? (
<header className="sticky top-0 z-50 border-b border-stone-200 bg-white/80 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/80">
<div className="shell flex min-h-14 flex-col gap-3 py-3 lg:flex-row lg:items-center lg:justify-between lg:py-2">
<div className="flex items-center justify-between gap-4">
<Link href="/" className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
className="h-9 w-9 rounded-md object-contain"
/>
<span>RentalDriveGo</span>
</Link>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<nav className="flex items-center gap-1 overflow-x-auto">
<Link
href="/"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.marketplace}
</Link>
<Link
href="/explore"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.explore}
</Link>
<Link
href="/sign-in"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.signIn}
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
className="ml-2 flex items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
{companyName.charAt(0).toUpperCase()}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-up`}
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{dict.ownerSignIn}
</Link>
)}
</nav>
<SegmentedControl
label={dict.theme}
value={theme}
options={[
{ value: 'light', label: dict.light },
{ value: 'medium', label: dict.medium },
{ value: 'dark', label: dict.dark },
]}
onChange={setTheme}
/>
</div>
</div>
</header>
) : null}
<div className="flex min-h-[calc(100vh-57px)] flex-col">
<div className="flex-1">
{children}
</div>
{!isEmbeddedWorkspace ? (
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
<div className="shell flex flex-col items-center gap-5 text-center">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.secondary.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<Globe className="h-4 w-4" />
<span>{footerContent.localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 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-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
) : null}
</div>
</div>
</PreferencesContext.Provider>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}