8aab968e09
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Comprehensive rename of all marketplace references to storefront: - API module: apps/api/src/modules/marketplace/ → storefront/ - Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell → StorefrontShell, MarketplaceFooter → StorefrontFooter - Types: marketplace-homepage.ts → storefront-homepage.ts - Test files: employee-marketplace-* → employee-storefront-* - All source code identifiers, imports, route paths, and strings - Documentation (docs/), CI config (.gitlab-ci.yml), scripts - Dashboard, admin, storefront workspace references - Prisma field names preserved (isListedOnMarketplace, marketplaceRating, marketplaceFunnelEvent) as they map to database schema Validation: - API type-check: 0 errors - Storefront type-check: 0 errors - Dashboard type-check: 0 errors - Full monorepo type-check: only pre-existing admin TS18046
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
|
|
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isStorefrontLanguage, type StorefrontLanguage } from '@/lib/i18n'
|
|
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
|
|
|
type Theme = 'light' | 'dark'
|
|
|
|
type Dictionary = {
|
|
storefront: string
|
|
explore: string
|
|
signIn: string
|
|
ownerSignIn: string
|
|
language: string
|
|
theme: string
|
|
light: string
|
|
dark: string
|
|
preferences: string
|
|
}
|
|
|
|
const dictionaries: Record<StorefrontLanguage, Dictionary> = {
|
|
en: {
|
|
storefront: 'Home',
|
|
explore: 'Storefront',
|
|
signIn: 'Sign in',
|
|
ownerSignIn: 'Create Agency Space',
|
|
language: 'Language',
|
|
theme: 'Theme',
|
|
light: 'Light',
|
|
dark: 'Dark',
|
|
preferences: 'Storefront preferences',
|
|
},
|
|
fr: {
|
|
storefront: 'Accueil',
|
|
explore: 'Storefront',
|
|
signIn: 'Connexion',
|
|
ownerSignIn: "Créer un espace d'agence",
|
|
language: 'Langue',
|
|
theme: 'Mode',
|
|
light: 'Clair',
|
|
dark: 'Sombre',
|
|
preferences: 'Préférences storefront',
|
|
},
|
|
ar: {
|
|
storefront: 'الرئيسية',
|
|
explore: 'السوق',
|
|
signIn: 'تسجيل الدخول',
|
|
ownerSignIn: 'إنشاء مساحة الوكالة',
|
|
language: 'اللغة',
|
|
theme: 'الوضع',
|
|
light: 'فاتح',
|
|
dark: 'داكن',
|
|
preferences: 'تفضيلات السوق',
|
|
},
|
|
}
|
|
|
|
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
|
|
|
export const localeOptions: Array<{ value: StorefrontLanguage; 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: StorefrontLanguage): {
|
|
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: appPrivacyHref.fr },
|
|
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
|
|
],
|
|
secondary: [
|
|
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
|
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
|
|
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
|
|
],
|
|
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: appPrivacyHref.ar },
|
|
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
|
|
],
|
|
secondary: [
|
|
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
|
|
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
|
|
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
|
|
],
|
|
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: appPrivacyHref.en },
|
|
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
|
|
],
|
|
secondary: [
|
|
{ label: 'Newsletter', href: footerPageHref.newsletter },
|
|
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
|
|
{ label: 'General Conditions', href: footerPageHref['general-conditions'] },
|
|
],
|
|
localeLabel: 'Global (English)',
|
|
rightsLabel: 'All rights reserved.',
|
|
}
|
|
}
|
|
}
|
|
|
|
export function hasCachedEmployeeProfile(): boolean {
|
|
if (typeof window === 'undefined') return false
|
|
|
|
try {
|
|
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function clearCachedEmployeeProfile() {
|
|
if (typeof window === 'undefined') return
|
|
|
|
try {
|
|
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
|
|
} catch {}
|
|
}
|
|
|
|
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: StorefrontLanguage
|
|
theme: Theme
|
|
dict: Dictionary
|
|
companyName: string | null
|
|
setLanguage: (language: StorefrontLanguage) => void
|
|
setTheme: (theme: Theme) => void
|
|
}
|
|
|
|
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
|
|
|
export function useStorefrontPreferences() {
|
|
const context = useContext(PreferencesContext)
|
|
if (!context) throw new Error('useStorefrontPreferences must be used within StorefrontShell')
|
|
return context
|
|
}
|
|
|
|
export default function StorefrontShell({
|
|
children,
|
|
initialLanguage = 'en',
|
|
initialTheme = 'light',
|
|
}: {
|
|
children: React.ReactNode
|
|
initialLanguage?: StorefrontLanguage
|
|
initialTheme?: Theme
|
|
}) {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
|
const [language, setLanguageState] = useState<StorefrontLanguage>(initialLanguage)
|
|
const [theme, setThemeState] = useState<Theme>(initialTheme)
|
|
const [hydrated, setHydrated] = useState(false)
|
|
const previousLanguage = useRef<StorefrontLanguage>(initialLanguage)
|
|
const [companyName, setCompanyName] = useState<string | null>(null)
|
|
|
|
function applyLanguage(nextLanguage: StorefrontLanguage) {
|
|
if (nextLanguage === language) return
|
|
|
|
if (typeof window !== 'undefined') {
|
|
document.documentElement.lang = nextLanguage
|
|
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
|
|
try {
|
|
sessionStorage.setItem('storefront-language', nextLanguage)
|
|
} catch {}
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['storefront-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, ['storefront-theme'])
|
|
}
|
|
|
|
setThemeState(nextTheme)
|
|
}
|
|
|
|
async function syncCompanyBrand() {
|
|
if (!hasCachedEmployeeProfile()) {
|
|
setCompanyName(null)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiUrl}/companies/me/brand`, {
|
|
credentials: 'include',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearCachedEmployeeProfile()
|
|
}
|
|
setCompanyName(null)
|
|
return
|
|
}
|
|
|
|
const json = await response.json()
|
|
const name = json?.data?.displayName ?? json?.displayName ?? null
|
|
setCompanyName(name)
|
|
} catch {
|
|
setCompanyName(null)
|
|
}
|
|
}
|
|
|
|
function readSessionLanguage(): StorefrontLanguage | null {
|
|
try {
|
|
const val = sessionStorage.getItem('storefront-language')
|
|
return isStorefrontLanguage(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 (isStorefrontLanguage(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, ['storefront-theme'])
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const sessionLang = readSessionLanguage()
|
|
if (sessionLang) {
|
|
setLanguageState(sessionLang)
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['storefront-language'])
|
|
} else {
|
|
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['storefront-language'])
|
|
if (isStorefrontLanguage(storedLanguage)) {
|
|
setLanguageState(storedLanguage)
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['storefront-language'])
|
|
} else {
|
|
const detected = detectBrowserLanguage()
|
|
if (isStorefrontLanguage(detected)) {
|
|
setLanguageState(detected)
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['storefront-language'])
|
|
}
|
|
}
|
|
}
|
|
|
|
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['storefront-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('storefront-language', language) } catch {}
|
|
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['storefront-language'])
|
|
|
|
if (previousLanguage.current !== language && pathname !== '/sign-in') {
|
|
router.refresh()
|
|
}
|
|
previousLanguage.current = language
|
|
}, [hydrated, language, pathname, router])
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.toggle('dark', theme === 'dark')
|
|
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
|
|
document.body.dataset.theme = theme
|
|
if (hydrated) {
|
|
writeScopedPreference(SHARED_THEME_KEY, theme, ['storefront-theme'])
|
|
}
|
|
}, [theme, hydrated])
|
|
|
|
const value = useMemo(
|
|
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
|
|
[language, theme, companyName],
|
|
)
|
|
|
|
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
|
|
}
|