update website home and dashboard

This commit is contained in:
root
2026-05-20 14:52:26 -04:00
parent d4f7de5762
commit fb15883f8e
38 changed files with 3043 additions and 1922 deletions
@@ -42,7 +42,7 @@ const copy = {
submit: 'Demander une réservation',
submitting: 'Envoi…',
successTitle: 'Demande reçue !',
successBody: 'Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.',
successBody: "Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.",
invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Veuillez remplir tous les champs obligatoires.',
perDay: '/ jour',
@@ -72,6 +72,7 @@ const copy = {
export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props) {
const { language } = useMarketplacePreferences()
const t = copy[language]
const [firstName, setFirstName] = useState('')
@@ -118,6 +119,7 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
startDate: start.toISOString(),
endDate: end.toISOString(),
notes: notes.trim() || undefined,
language,
})
setSuccess(true)
} catch (err: any) {
@@ -0,0 +1,123 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type LocaleOption = {
value: 'en' | 'fr' | 'ar'
label: string
flag: string
}
type FooterItem = {
label: string
href?: string
}
export default function MarketplaceFooter({
primaryItems,
secondaryItems,
localeLabel,
rightsLabel,
localeOptions,
currentLocale,
onSelectLanguage,
}: {
primaryItems: FooterItem[]
secondaryItems: FooterItem[]
localeLabel: string
rightsLabel: string
localeOptions: LocaleOption[]
currentLocale: LocaleOption
onSelectLanguage: (language: LocaleOption['value']) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<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">
{primaryItems.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < primaryItems.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">
{secondaryItems.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"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{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)]">
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(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"
>
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
<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. {rightsLabel}
</p>
</div>
</footer>
)
}
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>
}
@@ -0,0 +1,216 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type Theme = 'light' | 'medium' | 'dark'
type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
marketplace: string
explore: string
signIn: string
ownerSignIn: string
theme: string
light: string
medium: string
dark: string
}
type LanguageMeta = {
value: Language
flag: string
shortLabel: string
}
export default function MarketplaceHeader({
dict,
theme,
setTheme,
companyName,
dashboardUrl,
currentLanguage,
localeOptions,
onSelectLanguage,
}: {
dict: Dictionary
theme: Theme
setTheme: (theme: Theme) => void
companyName: string | null
dashboardUrl: string
currentLanguage: LanguageMeta
localeOptions: LanguageMeta[]
onSelectLanguage: (language: Language) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'medium' as const, label: dict.medium },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const localeMenuPositionClass =
currentLanguage.value === 'ar'
? 'right-0 sm:right-0'
: 'left-0 sm:left-auto sm:right-0'
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
return (
<header className="sticky top-0 z-50 border-b border-stone-200 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-stone-800 dark:bg-stone-950 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
<div className="flex items-center justify-between gap-3">
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</Link>
</div>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<Link
href="/"
className="rounded-full px-2.5 py-1 text-[12px] 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 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.marketplace}
</Link>
<Link
href="/explore"
className="rounded-full px-2.5 py-1 text-[12px] 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 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.explore}
</Link>
<Link
href="/sign-in"
className="rounded-full px-2.5 py-1 text-[12px] 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 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.signIn}
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
className="ml-1 flex items-center gap-1.5 rounded-full bg-stone-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<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-1 rounded-full bg-stone-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
>
{dict.ownerSignIn}
</Link>
)}
</nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-stone-700 dark:bg-stone-900/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-stone-800 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] 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)] ${localeMenuPositionClass}`}>
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(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"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-1 transition hover:bg-stone-100 dark:hover:bg-stone-800 sm:gap-2 sm:px-2 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="px-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400 sm:px-2 sm:text-[11px] sm:tracking-[0.2em]">
{dict.theme}
</span>
<span className="rounded-full bg-stone-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-amber-400 dark:text-stone-950 sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] 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)] ${localeMenuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between 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"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
)
}
@@ -1,10 +1,9 @@
'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 MarketplaceFooter from '@/components/MarketplaceFooter'
import MarketplaceHeader from '@/components/MarketplaceHeader'
import { footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
@@ -30,7 +29,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'Home',
explore: 'Marketplace',
signIn: 'Sign in',
ownerSignIn: 'Create account',
ownerSignIn: 'Create Agency Space',
language: 'Language',
theme: 'Theme',
light: 'Light',
@@ -42,7 +41,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'Accueil',
explore: 'Marketplace',
signIn: 'Connexion',
ownerSignIn: 'Créer un compte',
ownerSignIn: "Créer Espace d'Agence",
language: 'Langue',
theme: 'Mode',
light: 'Clair',
@@ -54,7 +53,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء حساب',
ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
@@ -74,20 +73,15 @@ type PreferencesContextValue = {
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)' },
const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'NorthAfrica (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europ (French)', flag: '🇫🇷' },
]
function getFooterContent(language: MarketplaceLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }>
localeLabel: string
rightsLabel: string
} {
@@ -150,45 +144,6 @@ function getFooterContent(language: MarketplaceLanguage): {
}
}
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>
)
}
function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
@@ -205,19 +160,50 @@ export function useMarketplacePreferences() {
return context
}
export default function MarketplaceShell({ children }: { children: React.ReactNode }) {
export default function MarketplaceShell({
children,
initialLanguage = 'en',
}: {
children: React.ReactNode
initialLanguage?: MarketplaceLanguage
}) {
const pathname = usePathname()
const router = useRouter()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard')
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002/admin')
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 [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>('light')
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>('en')
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
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' || nextTheme === 'medium')
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
@@ -249,36 +235,63 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
}
}
function syncScopedPreferencesForSession() {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
function readSessionLanguage(): MarketplaceLanguage | null {
try {
const val = sessionStorage.getItem('marketplace-language')
return isMarketplaceLanguage(val) ? val : null
} catch {
return null
}
}
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguage(scopedLanguage)
function syncScopedPreferencesForSession() {
// Tab-session choice always wins — never let an employee-scoped cookie override
// a language the user explicitly picked during this browsing session.
const sessionLang = readSessionLanguage()
if (sessionLang) {
if (sessionLang !== language) setLanguageState(sessionLang)
} else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
}
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if ((scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') && scopedTheme !== theme) {
setTheme(scopedTheme)
setThemeState(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)
// sessionStorage is tab-local — check it first so a selection made earlier in
// this tab is honoured even after a server refresh.
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
// Write the persistent cookie immediately so any subsequent RSC request
// (e.g. router.refresh or next navigation) sees the correct language
// without waiting for the language effect's render cycle.
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) setLanguage(detected)
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 === 'medium' || storedTheme === 'dark') {
setTheme(storedTheme)
setThemeState(storedTheme)
}
setHydrated(true)
@@ -316,195 +329,77 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
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) {
// Don't write to storage on the initial render (language='en' default) — wait
// until hydration has resolved the correct stored preference. Writing 'en'
// here before hydration would overwrite a previously-saved 'ar' preference.
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, theme])
}, [hydrated, language, pathname, router])
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
document.body.dataset.theme = theme
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}, [theme])
const dict = dictionaries[language]
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const currentLocale = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
const isRenterApp = pathname.startsWith('/renter/')
const isEmbeddedWorkspace = isRenterApp
const hideHeader = isRenterApp
const isAuthFrameRoute = pathname === '/sign-in'
const value = useMemo(
() => ({ language, theme, dict, setLanguage, setTheme }),
() => ({ language, theme, dict, setLanguage: applyLanguage, setTheme: applyTheme }),
[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 min-h-screen flex-col bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100">
{isRenterApp || isAuthFrameRoute ? null : (
<MarketplaceHeader
dict={dict}
theme={theme}
setTheme={applyTheme}
companyName={companyName}
dashboardUrl={dashboardUrl}
currentLanguage={{
value: currentLocale.value,
flag: currentLocale.flag,
shortLabel: currentLocale.value.toUpperCase(),
}}
localeOptions={availableLocaleOptions.map((option) => ({
value: option.value,
flag: option.flag,
shortLabel: option.value.toUpperCase(),
}))}
onSelectLanguage={applyLanguage}
/>
)}
<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 className="flex flex-1 flex-col">
<div className="flex-1">{children}</div>
{isRenterApp || isAuthFrameRoute ? null : (
<MarketplaceFooter
primaryItems={footerContent.primary}
secondaryItems={footerContent.secondary}
localeLabel={footerContent.localeLabel}
rightsLabel={footerContent.rightsLabel}
localeOptions={availableLocaleOptions}
currentLocale={currentLocale}
onSelectLanguage={applyLanguage}
/>
)}
</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>
}
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useMarketplacePreferences } from './MarketplaceShell'
@@ -44,11 +45,13 @@ function buildFrameUrl(appUrl: string, path: string) {
}
export default function WorkspaceFrame({ target }: { target: FrameId }) {
const pathname = usePathname()
const { language, theme } = useMarketplacePreferences()
const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target]
const frameHeight = 'calc(100vh - 56px)'
const isStandaloneAuthRoute = target === 'sign-in' && pathname === '/sign-in'
const frameHeight = isStandaloneAuthRoute ? '100vh' : 'calc(100vh - 56px)'
const loadingLabel = {
en: 'Loading',
fr: 'Chargement de',
@@ -59,6 +62,13 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
setFramePath(getDefaultFramePath(target))
}, [target])
useEffect(() => {
if (!isStandaloneAuthRoute || typeof window === 'undefined') return
if (window.location.pathname !== '/sign-in') return
window.history.replaceState(window.history.state, '', '/')
}, [isStandaloneAuthRoute])
useEffect(() => {
function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
@@ -78,8 +88,13 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath))
nextUrl.searchParams.set('theme', theme)
nextUrl.searchParams.set('lang', language)
if (isStandaloneAuthRoute) {
nextUrl.searchParams.delete('embedded')
} else {
nextUrl.searchParams.set('embedded', '1')
}
setSrc(nextUrl.toString())
}, [config.appUrl, framePath, language, theme])
}, [config.appUrl, framePath, isStandaloneAuthRoute, language, theme])
return (
<main className="bg-stone-950" style={{ minHeight: frameHeight }}>