diff --git a/Dockerfile.dev b/Dockerfile.dev index 49df1cb..e42cd66 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -12,6 +12,6 @@ COPY config ./config RUN npm ci --no-fund --no-audit -EXPOSE 3000 3001 3002 3003 4000 +EXPOSE 3000 3001 3002 3004 4000 CMD ["sh", "-c", "npm run db:generate && npm run dev"] diff --git a/Dockerfile.production b/Dockerfile.production index 920314f..fa3a9c9 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -54,6 +54,6 @@ RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh ENTRYPOINT ["/usr/local/bin/rdg-entrypoint.sh"] -EXPOSE 3000 3001 3002 4000 +EXPOSE 3000 3001 3002 3004 4000 CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"] diff --git a/apps/marketplace/next-env.d.ts b/apps/homepage/next-env.d.ts similarity index 100% rename from apps/marketplace/next-env.d.ts rename to apps/homepage/next-env.d.ts diff --git a/apps/marketplace/next.config.js b/apps/homepage/next.config.js similarity index 100% rename from apps/marketplace/next.config.js rename to apps/homepage/next.config.js diff --git a/apps/marketplace/package.json b/apps/homepage/package.json similarity index 89% rename from apps/marketplace/package.json rename to apps/homepage/package.json index 7e0b905..f75d771 100644 --- a/apps/marketplace/package.json +++ b/apps/homepage/package.json @@ -1,5 +1,5 @@ { - "name": "@rentaldrivego/marketplace", + "name": "@rentaldrivego/homepage", "version": "1.0.0", "private": true, "scripts": { @@ -17,14 +17,12 @@ "dependencies": { "@rentaldrivego/types": "*", "autoprefixer": "^10.4.19", - "dayjs": "^1.11.11", "lucide-react": "^0.376.0", "next": "^16.2.7", "postcss": "^8.4.38", "react": "^18.3.1", "react-dom": "^18.3.1", - "tailwindcss": "^3.4.3", - "zod": "^3.23.0" + "tailwindcss": "^3.4.3" }, "devDependencies": { "@types/node": "^20.12.0", diff --git a/apps/marketplace/postcss.config.js b/apps/homepage/postcss.config.js similarity index 100% rename from apps/marketplace/postcss.config.js rename to apps/homepage/postcss.config.js diff --git a/apps/marketplace/public/favicon.ico b/apps/homepage/public/favicon.ico similarity index 100% rename from apps/marketplace/public/favicon.ico rename to apps/homepage/public/favicon.ico diff --git a/apps/marketplace/public/icon.svg b/apps/homepage/public/icon.svg similarity index 100% rename from apps/marketplace/public/icon.svg rename to apps/homepage/public/icon.svg diff --git a/apps/marketplace/public/rentalcardrive.png b/apps/homepage/public/rentalcardrive.png similarity index 100% rename from apps/marketplace/public/rentalcardrive.png rename to apps/homepage/public/rentalcardrive.png diff --git a/apps/marketplace/src/app/(public)/HomeContent.tsx b/apps/homepage/src/app/(public)/HomeContent.tsx similarity index 99% rename from apps/marketplace/src/app/(public)/HomeContent.tsx rename to apps/homepage/src/app/(public)/HomeContent.tsx index dad9741..b404037 100644 --- a/apps/marketplace/src/app/(public)/HomeContent.tsx +++ b/apps/homepage/src/app/(public)/HomeContent.tsx @@ -72,7 +72,7 @@ export default function HomeContent() { {dict.startTrial} {dict.exploreVehicles} diff --git a/apps/marketplace/src/app/(public)/app-policy-pages.test.ts b/apps/homepage/src/app/(public)/app-policy-pages.test.ts similarity index 100% rename from apps/marketplace/src/app/(public)/app-policy-pages.test.ts rename to apps/homepage/src/app/(public)/app-policy-pages.test.ts diff --git a/apps/marketplace/src/app/(public)/app-privacy-ar/page.tsx b/apps/homepage/src/app/(public)/app-privacy-ar/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/app-privacy-ar/page.tsx rename to apps/homepage/src/app/(public)/app-privacy-ar/page.tsx diff --git a/apps/marketplace/src/app/(public)/app-privacy-en/page.tsx b/apps/homepage/src/app/(public)/app-privacy-en/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/app-privacy-en/page.tsx rename to apps/homepage/src/app/(public)/app-privacy-en/page.tsx diff --git a/apps/marketplace/src/app/(public)/app-privacy-fr/page.tsx b/apps/homepage/src/app/(public)/app-privacy-fr/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/app-privacy-fr/page.tsx rename to apps/homepage/src/app/(public)/app-privacy-fr/page.tsx diff --git a/apps/marketplace/src/app/(public)/app-tc-ar/page.tsx b/apps/homepage/src/app/(public)/app-tc-ar/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/app-tc-ar/page.tsx rename to apps/homepage/src/app/(public)/app-tc-ar/page.tsx diff --git a/apps/marketplace/src/app/(public)/app-tc-en/page.tsx b/apps/homepage/src/app/(public)/app-tc-en/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/app-tc-en/page.tsx rename to apps/homepage/src/app/(public)/app-tc-en/page.tsx diff --git a/apps/marketplace/src/app/(public)/app-tc-fr/page.tsx b/apps/homepage/src/app/(public)/app-tc-fr/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/app-tc-fr/page.tsx rename to apps/homepage/src/app/(public)/app-tc-fr/page.tsx diff --git a/apps/marketplace/src/app/(public)/company-workspace/page.tsx b/apps/homepage/src/app/(public)/company-workspace/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/company-workspace/page.tsx rename to apps/homepage/src/app/(public)/company-workspace/page.tsx diff --git a/apps/marketplace/src/app/(public)/features/page.tsx b/apps/homepage/src/app/(public)/features/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/features/page.tsx rename to apps/homepage/src/app/(public)/features/page.tsx diff --git a/apps/marketplace/src/app/(public)/footer/[slug]/page.test.ts b/apps/homepage/src/app/(public)/footer/[slug]/page.test.ts similarity index 100% rename from apps/marketplace/src/app/(public)/footer/[slug]/page.test.ts rename to apps/homepage/src/app/(public)/footer/[slug]/page.test.ts diff --git a/apps/marketplace/src/app/(public)/footer/[slug]/page.tsx b/apps/homepage/src/app/(public)/footer/[slug]/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/footer/[slug]/page.tsx rename to apps/homepage/src/app/(public)/footer/[slug]/page.tsx diff --git a/apps/marketplace/src/app/(public)/layout.tsx b/apps/homepage/src/app/(public)/layout.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/layout.tsx rename to apps/homepage/src/app/(public)/layout.tsx diff --git a/apps/marketplace/src/app/(public)/page.tsx b/apps/homepage/src/app/(public)/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/page.tsx rename to apps/homepage/src/app/(public)/page.tsx diff --git a/apps/marketplace/src/app/(public)/platform-operations/page.tsx b/apps/homepage/src/app/(public)/platform-operations/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/platform-operations/page.tsx rename to apps/homepage/src/app/(public)/platform-operations/page.tsx diff --git a/apps/marketplace/src/app/(public)/pricing/PricingClient.tsx b/apps/homepage/src/app/(public)/pricing/PricingClient.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/pricing/PricingClient.tsx rename to apps/homepage/src/app/(public)/pricing/PricingClient.tsx diff --git a/apps/marketplace/src/app/(public)/pricing/PricingPageContent.tsx b/apps/homepage/src/app/(public)/pricing/PricingPageContent.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/pricing/PricingPageContent.tsx rename to apps/homepage/src/app/(public)/pricing/PricingPageContent.tsx diff --git a/apps/marketplace/src/app/(public)/pricing/page.tsx b/apps/homepage/src/app/(public)/pricing/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/pricing/page.tsx rename to apps/homepage/src/app/(public)/pricing/page.tsx diff --git a/apps/marketplace/src/app/(public)/review/page.tsx b/apps/homepage/src/app/(public)/review/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/review/page.tsx rename to apps/homepage/src/app/(public)/review/page.tsx diff --git a/apps/marketplace/src/app/globals.css b/apps/homepage/src/app/globals.css similarity index 100% rename from apps/marketplace/src/app/globals.css rename to apps/homepage/src/app/globals.css diff --git a/apps/marketplace/src/app/layout.tsx b/apps/homepage/src/app/layout.tsx similarity index 100% rename from apps/marketplace/src/app/layout.tsx rename to apps/homepage/src/app/layout.tsx diff --git a/apps/marketplace/src/components/FeatureAlternating.tsx b/apps/homepage/src/components/FeatureAlternating.tsx similarity index 100% rename from apps/marketplace/src/components/FeatureAlternating.tsx rename to apps/homepage/src/components/FeatureAlternating.tsx diff --git a/apps/marketplace/src/components/FooterContentPage.test.ts b/apps/homepage/src/components/FooterContentPage.test.ts similarity index 100% rename from apps/marketplace/src/components/FooterContentPage.test.ts rename to apps/homepage/src/components/FooterContentPage.test.ts diff --git a/apps/marketplace/src/components/FooterContentPage.tsx b/apps/homepage/src/components/FooterContentPage.tsx similarity index 100% rename from apps/marketplace/src/components/FooterContentPage.tsx rename to apps/homepage/src/components/FooterContentPage.tsx diff --git a/apps/marketplace/src/components/HowItWorks.tsx b/apps/homepage/src/components/HowItWorks.tsx similarity index 100% rename from apps/marketplace/src/components/HowItWorks.tsx rename to apps/homepage/src/components/HowItWorks.tsx diff --git a/apps/marketplace/src/components/MarketplaceFooter.tsx b/apps/homepage/src/components/MarketplaceFooter.tsx similarity index 100% rename from apps/marketplace/src/components/MarketplaceFooter.tsx rename to apps/homepage/src/components/MarketplaceFooter.tsx diff --git a/apps/marketplace/src/components/MarketplaceHeader.boundary.test.ts b/apps/homepage/src/components/MarketplaceHeader.boundary.test.ts similarity index 100% rename from apps/marketplace/src/components/MarketplaceHeader.boundary.test.ts rename to apps/homepage/src/components/MarketplaceHeader.boundary.test.ts diff --git a/apps/homepage/src/components/MarketplaceHeader.tsx b/apps/homepage/src/components/MarketplaceHeader.tsx new file mode 100644 index 0000000..7e01f9b --- /dev/null +++ b/apps/homepage/src/components/MarketplaceHeader.tsx @@ -0,0 +1,237 @@ +'use client' + +import { ChevronDown } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { useEffect, useRef, useState } from 'react' + +export type Theme = 'light' | 'dark' +export type Language = 'en' | 'fr' | 'ar' + +type Dictionary = { + home: string + features: string + pricing: string + signIn: string + ownerSignIn: string + theme: string + light: string + dark: string +} + +type LanguageMeta = { + value: Language + flag: string + shortLabel: string +} + + +export function buildDashboardSignInHref(dashboardUrl: string, language: Language, theme: Theme): string { + const signInParams = new URLSearchParams() + signInParams.set('lang', language) + signInParams.set('theme', theme) + return `${dashboardUrl}/sign-in?${signInParams.toString()}` +} + +export function localeMenuPositionClass(language: Language): string { + return language === 'ar' ? 'right-0 sm:right-0' : 'left-0 sm:left-auto sm:right-0' +} + +export function ownerWorkspaceHref(dashboardUrl: string, companyName: string | null): string { + return companyName ? dashboardUrl : `${dashboardUrl}/sign-up` +} + +export function companyInitial(companyName: string): string { + return companyName.trim().charAt(0).toUpperCase() +} + +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(null) + const [localeMenuOpen, setLocaleMenuOpen] = useState(false) + const themeMenuRef = useRef(null) + const [themeMenuOpen, setThemeMenuOpen] = useState(false) + const themeOptions = [ + { value: 'light' as const, label: dict.light }, + { value: 'dark' as const, label: dict.dark }, + ] + const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0] + const menuPositionClass = localeMenuPositionClass(currentLanguage.value) + + 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) + } + + const signInHref = buildDashboardSignInHref(dashboardUrl, currentLanguage.value, theme) + + return ( +
+
+
+ + FleetOS + FleetOS + +
+ +
+ +
+
+ + {localeMenuOpen ? ( +
+ {localeOptions.map((option) => ( + + ))} +
+ ) : null} +
+
+
+
+
+ ) +} diff --git a/apps/marketplace/src/components/MarketplaceShell.auth.test.ts b/apps/homepage/src/components/MarketplaceShell.auth.test.ts similarity index 100% rename from apps/marketplace/src/components/MarketplaceShell.auth.test.ts rename to apps/homepage/src/components/MarketplaceShell.auth.test.ts diff --git a/apps/marketplace/src/components/MarketplaceShell.content.test.ts b/apps/homepage/src/components/MarketplaceShell.content.test.ts similarity index 100% rename from apps/marketplace/src/components/MarketplaceShell.content.test.ts rename to apps/homepage/src/components/MarketplaceShell.content.test.ts diff --git a/apps/homepage/src/components/MarketplaceShell.tsx b/apps/homepage/src/components/MarketplaceShell.tsx new file mode 100644 index 0000000..f39af1f --- /dev/null +++ b/apps/homepage/src/components/MarketplaceShell.tsx @@ -0,0 +1,368 @@ +'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, 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 = { + home: string + features: string + pricing: string + signIn: string + ownerSignIn: string + language: string + theme: string + light: string + dark: string + preferences: string +} + +const dictionaries: Record = { + en: { + home: 'Home', + features: 'Features', + pricing: 'Pricing', + signIn: 'Sign in', + ownerSignIn: 'Get Started', + language: 'Language', + theme: 'Theme', + light: 'Light', + dark: 'Dark', + preferences: 'Preferences', + }, + fr: { + home: 'Accueil', + features: 'Fonctionnalités', + pricing: 'Tarifs', + signIn: 'Connexion', + ownerSignIn: 'Démarrer', + language: 'Langue', + theme: 'Mode', + light: 'Clair', + dark: 'Sombre', + preferences: 'Préférences', + }, + ar: { + home: 'الرئيسية', + features: 'الميزات', + pricing: 'الأسعار', + signIn: 'تسجيل الدخول', + ownerSignIn: 'ابدأ الآن', + language: 'اللغة', + theme: 'الوضع', + light: 'فاتح', + dark: 'داكن', + preferences: 'التفضيلات', + }, +} + +const EMPLOYEE_PROFILE_KEY = 'employee_profile' + +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: 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: MarketplaceLanguage + theme: Theme + dict: Dictionary + companyName: string | null + setLanguage: (language: MarketplaceLanguage) => void + setTheme: (theme: Theme) => void +} + +const PreferencesContext = createContext(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', + initialTheme = 'light', +}: { + children: React.ReactNode + initialLanguage?: MarketplaceLanguage + 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(initialLanguage) + const [theme, setThemeState] = useState(initialTheme) + const [hydrated, setHydrated] = useState(false) + const previousLanguage = useRef(initialLanguage) + const [companyName, setCompanyName] = useState(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() { + 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(): 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 && 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, ['marketplace-theme']) + } + }, [theme, hydrated]) + + const value = useMemo( + () => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }), + [language, theme, companyName], + ) + + return {children} +} diff --git a/apps/marketplace/src/components/PricingToggle.tsx b/apps/homepage/src/components/PricingToggle.tsx similarity index 100% rename from apps/marketplace/src/components/PricingToggle.tsx rename to apps/homepage/src/components/PricingToggle.tsx diff --git a/apps/marketplace/src/components/StatsStrip.tsx b/apps/homepage/src/components/StatsStrip.tsx similarity index 100% rename from apps/marketplace/src/components/StatsStrip.tsx rename to apps/homepage/src/components/StatsStrip.tsx diff --git a/apps/marketplace/src/components/TestimonialsSection.tsx b/apps/homepage/src/components/TestimonialsSection.tsx similarity index 100% rename from apps/marketplace/src/components/TestimonialsSection.tsx rename to apps/homepage/src/components/TestimonialsSection.tsx diff --git a/apps/marketplace/src/components/WorkspaceTabs.tsx b/apps/homepage/src/components/WorkspaceTabs.tsx similarity index 98% rename from apps/marketplace/src/components/WorkspaceTabs.tsx rename to apps/homepage/src/components/WorkspaceTabs.tsx index efbe5df..b2ff2b0 100644 --- a/apps/marketplace/src/components/WorkspaceTabs.tsx +++ b/apps/homepage/src/components/WorkspaceTabs.tsx @@ -151,7 +151,7 @@ export default function WorkspaceTabs() {
{copy.customerPaths}

- + {copy.exploreVehicles} +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/storefront/next.config.js b/apps/storefront/next.config.js new file mode 100644 index 0000000..da5d24b --- /dev/null +++ b/apps/storefront/next.config.js @@ -0,0 +1,74 @@ +/** @type {import('next').NextConfig} */ + +const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders') + +// The marketplace proxies /dashboard and /admin to their own Next dev servers. +// Allow those asset-prefix origins so proxied pages can load chunks and HMR. +const dashboardAssetSource = normalizeAssetPrefix( + process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined), + '/dashboard', +) +const securityHeaders = buildSecurityHeaders({ + assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX], +}) +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'res.cloudinary.com', + }, + ], + }, + transpilePackages: ['@rentaldrivego/types'], + async headers() { + return [ + { + source: '/:path*', + headers: securityHeaders, + }, + ] + }, + async redirects() { + return [ + { + source: '/dashboard/dashboard', + destination: '/dashboard', + permanent: false, + }, + { + source: '/dashboard/dashboard/:path*', + destination: '/dashboard/:path*', + permanent: false, + }, + ] + }, + async rewrites() { + const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001' + const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002' + + return [ + // Dashboard has basePath '/dashboard'. The proxy preserves the prefix + // so the dashboard can strip it and route internally. + { + source: '/dashboard', + destination: `${dashboardOrigin}/dashboard`, + }, + { + source: '/dashboard/:path*', + destination: `${dashboardOrigin}/dashboard/:path*`, + }, + // Admin routes (also uses basePath) + { + source: '/admin', + destination: `${adminOrigin}/admin`, + }, + { + source: '/admin/:path*', + destination: `${adminOrigin}/admin/:path*`, + }, + ] + }, +} + +module.exports = nextConfig diff --git a/apps/storefront/package.json b/apps/storefront/package.json new file mode 100644 index 0000000..337d1c4 --- /dev/null +++ b/apps/storefront/package.json @@ -0,0 +1,34 @@ +{ + "name": "@rentaldrivego/storefront", + "version": "1.0.0", + "private": true, + "scripts": { + "predev": "npm run build --workspace @rentaldrivego/types", + "dev": "next dev -H 0.0.0.0 -p 3004", + "prebuild": "npm run build --workspace @rentaldrivego/types", + "build": "next build", + "prestart": "npm run build --workspace @rentaldrivego/types", + "pretype-check": "npm run build --workspace @rentaldrivego/types", + "start": "next start -H 0.0.0.0 -p 3004", + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@rentaldrivego/types": "*", + "autoprefixer": "^10.4.19", + "lucide-react": "^0.376.0", + "next": "^16.2.7", + "postcss": "^8.4.38", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.3" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "typescript": "^5.4.0", + "vitest": "^1.6.0" + } +} diff --git a/apps/storefront/postcss.config.js b/apps/storefront/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/apps/storefront/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/storefront/public/favicon.ico b/apps/storefront/public/favicon.ico new file mode 100644 index 0000000..2fee055 Binary files /dev/null and b/apps/storefront/public/favicon.ico differ diff --git a/apps/storefront/public/icon.svg b/apps/storefront/public/icon.svg new file mode 100644 index 0000000..d2dee78 --- /dev/null +++ b/apps/storefront/public/icon.svg @@ -0,0 +1,14 @@ + + + + R + + diff --git a/apps/storefront/public/rentalcardrive.png b/apps/storefront/public/rentalcardrive.png new file mode 100644 index 0000000..2fee055 Binary files /dev/null and b/apps/storefront/public/rentalcardrive.png differ diff --git a/apps/storefront/src/app/(public)/app-policy-pages.test.ts b/apps/storefront/src/app/(public)/app-policy-pages.test.ts new file mode 100644 index 0000000..5729758 --- /dev/null +++ b/apps/storefront/src/app/(public)/app-policy-pages.test.ts @@ -0,0 +1,31 @@ +import React, { isValidElement } from 'react' +import { describe, expect, it } from 'vitest' +import FooterContentPage from '@/components/FooterContentPage' +import AppPrivacyEnPage from './app-privacy-en/page' +import AppPrivacyFrPage from './app-privacy-fr/page' +import AppPrivacyArPage from './app-privacy-ar/page' +import AppTermsEnPage from './app-tc-en/page' +import AppTermsFrPage from './app-tc-fr/page' +import AppTermsArPage from './app-tc-ar/page' + +function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) { + expect(isValidElement(element)).toBe(true) + const page = element as React.ReactElement + expect(page.type).toBe(FooterContentPage) + expect(page.props.slug).toBe(slug) + expect(page.props.forcedLanguage).toBe(forcedLanguage) +} + +describe('marketplace static app policy pages', () => { + it('binds app privacy pages to explicit locales', () => { + expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en') + expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr') + expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar') + }) + + it('binds app terms pages to explicit locales and the app terms content slug', () => { + expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en') + expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr') + expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar') + }) +}) diff --git a/apps/storefront/src/app/(public)/app-privacy-ar/page.tsx b/apps/storefront/src/app/(public)/app-privacy-ar/page.tsx new file mode 100644 index 0000000..76b8cce --- /dev/null +++ b/apps/storefront/src/app/(public)/app-privacy-ar/page.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppPrivacyArPage() { + return +} diff --git a/apps/storefront/src/app/(public)/app-privacy-en/page.tsx b/apps/storefront/src/app/(public)/app-privacy-en/page.tsx new file mode 100644 index 0000000..202d028 --- /dev/null +++ b/apps/storefront/src/app/(public)/app-privacy-en/page.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppPrivacyEnPage() { + return +} diff --git a/apps/storefront/src/app/(public)/app-privacy-fr/page.tsx b/apps/storefront/src/app/(public)/app-privacy-fr/page.tsx new file mode 100644 index 0000000..59d917a --- /dev/null +++ b/apps/storefront/src/app/(public)/app-privacy-fr/page.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppPrivacyFrPage() { + return +} diff --git a/apps/storefront/src/app/(public)/app-tc-ar/page.tsx b/apps/storefront/src/app/(public)/app-tc-ar/page.tsx new file mode 100644 index 0000000..5f6d7a7 --- /dev/null +++ b/apps/storefront/src/app/(public)/app-tc-ar/page.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppTermsArPage() { + return +} diff --git a/apps/storefront/src/app/(public)/app-tc-en/page.tsx b/apps/storefront/src/app/(public)/app-tc-en/page.tsx new file mode 100644 index 0000000..9e4f5e4 --- /dev/null +++ b/apps/storefront/src/app/(public)/app-tc-en/page.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppTermsEnPage() { + return +} diff --git a/apps/storefront/src/app/(public)/app-tc-fr/page.tsx b/apps/storefront/src/app/(public)/app-tc-fr/page.tsx new file mode 100644 index 0000000..54c7443 --- /dev/null +++ b/apps/storefront/src/app/(public)/app-tc-fr/page.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import FooterContentPage from '@/components/FooterContentPage' + +export default function AppTermsFrPage() { + return +} diff --git a/apps/storefront/src/app/(public)/company-workspace/page.tsx b/apps/storefront/src/app/(public)/company-workspace/page.tsx new file mode 100644 index 0000000..174130b --- /dev/null +++ b/apps/storefront/src/app/(public)/company-workspace/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function CompanyWorkspacePage() { + redirect('/') +} diff --git a/apps/marketplace/src/app/(public)/explore/ExploreSearchForm.tsx b/apps/storefront/src/app/(public)/explore/ExploreSearchForm.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/explore/ExploreSearchForm.tsx rename to apps/storefront/src/app/(public)/explore/ExploreSearchForm.tsx diff --git a/apps/marketplace/src/app/(public)/explore/ExploreVehicleGrid.tsx b/apps/storefront/src/app/(public)/explore/ExploreVehicleGrid.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/explore/ExploreVehicleGrid.tsx rename to apps/storefront/src/app/(public)/explore/ExploreVehicleGrid.tsx diff --git a/apps/marketplace/src/app/(public)/explore/[slug]/page.tsx b/apps/storefront/src/app/(public)/explore/[slug]/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/explore/[slug]/page.tsx rename to apps/storefront/src/app/(public)/explore/[slug]/page.tsx diff --git a/apps/marketplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx b/apps/storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx rename to apps/storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx diff --git a/apps/marketplace/src/app/(public)/explore/page.tsx b/apps/storefront/src/app/(public)/explore/page.tsx similarity index 100% rename from apps/marketplace/src/app/(public)/explore/page.tsx rename to apps/storefront/src/app/(public)/explore/page.tsx diff --git a/apps/storefront/src/app/(public)/footer/[slug]/page.test.ts b/apps/storefront/src/app/(public)/footer/[slug]/page.test.ts new file mode 100644 index 0000000..ac35998 --- /dev/null +++ b/apps/storefront/src/app/(public)/footer/[slug]/page.test.ts @@ -0,0 +1,34 @@ +import React, { isValidElement } from 'react' +import { describe, expect, it, vi } from 'vitest' + +const navigation = vi.hoisted(() => ({ + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND') + }), +})) + +vi.mock('next/navigation', () => navigation) + +import FooterContentPage from '@/components/FooterContentPage' +import FooterPage, { generateStaticParams } from './page' +import { footerPageSlugs } from '@/lib/footerContent' + +describe('marketplace footer route', () => { + it('generates one static route per registered footer slug', () => { + expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug }))) + }) + + it('passes valid slugs through to FooterContentPage', () => { + const element = FooterPage({ params: { slug: 'privacy-policy' } }) + + expect(isValidElement(element)).toBe(true) + expect(element.type).toBe(FooterContentPage) + expect(element.props.slug).toBe('privacy-policy') + expect(element.props.forcedLanguage).toBeUndefined() + }) + + it('rejects unknown slugs instead of rendering arbitrary policy pages', () => { + expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND') + expect(navigation.notFound).toHaveBeenCalled() + }) +}) diff --git a/apps/storefront/src/app/(public)/footer/[slug]/page.tsx b/apps/storefront/src/app/(public)/footer/[slug]/page.tsx new file mode 100644 index 0000000..f9ddf5a --- /dev/null +++ b/apps/storefront/src/app/(public)/footer/[slug]/page.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { notFound } from 'next/navigation' +import FooterContentPage from '@/components/FooterContentPage' +import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent' + +export function generateStaticParams() { + return footerPageSlugs.map((slug) => ({ slug })) +} + +export default function FooterPage({ + params, +}: { + params: { slug: string } +}) { + const { slug } = params + + if (!isFooterPageSlug(slug)) { + notFound() + } + + return +} diff --git a/apps/storefront/src/app/(public)/layout.tsx b/apps/storefront/src/app/(public)/layout.tsx new file mode 100644 index 0000000..dfe401d --- /dev/null +++ b/apps/storefront/src/app/(public)/layout.tsx @@ -0,0 +1,41 @@ +'use client' + +import MarketplaceHeader from '@/components/MarketplaceHeader' +import MarketplaceFooter from '@/components/MarketplaceFooter' +import { useMarketplacePreferences, getFooterContent, localeOptions } from '@/components/MarketplaceShell' +import { resolveBrowserAppUrl } from '@/lib/appUrls' + +export default function PublicLayout({ children }: { children: React.ReactNode }) { + const { language, theme, dict, companyName, setLanguage, setTheme } = useMarketplacePreferences() + const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard') + const footerContent = getFooterContent(language) + const available = localeOptions.filter((o) => o.value !== language) + const current = localeOptions.find((o) => o.value === language) ?? localeOptions[0] + + return ( +
+ ({ value: o.value, flag: o.flag, shortLabel: o.value.toUpperCase() }))} + onSelectLanguage={setLanguage} + /> +
+
{children}
+ +
+
+ ) +} diff --git a/apps/storefront/src/app/(public)/page.tsx b/apps/storefront/src/app/(public)/page.tsx new file mode 100644 index 0000000..b227514 --- /dev/null +++ b/apps/storefront/src/app/(public)/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function StorefrontHomePage() { + redirect('/explore') +} diff --git a/apps/storefront/src/app/globals.css b/apps/storefront/src/app/globals.css new file mode 100644 index 0000000..a493d66 --- /dev/null +++ b/apps/storefront/src/app/globals.css @@ -0,0 +1,108 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100; +} + +html.dark body { + background-image: + linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%); +} + +.shell { + @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8; +} + +.card { + @apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)]; + border-color: rgb(231 229 228 / 0.8); + background-color: rgb(255 255 255 / 0.82); +} + +html.dark .card { + border-color: rgb(30 60 140 / 0.40); + background-color: rgb(11 25 55 / 0.72); +} + +.site-page { + @apply min-h-screen overflow-hidden text-blue-950; + background-image: + linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%); +} + +html.dark .site-page { + @apply text-slate-100; + background-image: + linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%); +} + +.site-glow { + background-image: + radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%), + radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%); +} + +html.dark .site-glow { + background-image: + radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%), + radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 26%); +} + +.site-section { + @apply shell py-10 sm:py-14 lg:py-16; +} + +.site-panel { + @apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8; + border-color: rgb(231 229 228 / 0.8); + background-color: rgb(255 255 255 / 0.82); +} + +html.dark .site-panel { + border-color: rgb(30 60 140 / 0.40); + background-color: rgb(11 25 55 / 0.72); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36); +} + +.site-panel-muted { + @apply rounded-[2rem] border p-7 transition-colors sm:p-8; + border-color: rgb(231 229 228 / 0.8); + background-image: linear-gradient(160deg, #f5f8ff 0%, #edf2ff 100%); +} + +html.dark .site-panel-muted { + border-color: rgb(30 60 140 / 0.40); + background-image: linear-gradient(160deg, #0c1830 0%, #10203e 100%); +} + +.site-panel-contrast { + @apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8; + border-color: rgb(14 40 90 / 0.8); + background-color: rgb(10 25 75); +} + +html.dark .site-panel-contrast { + border-color: rgb(30 64 175); +} + +.site-kicker { + @apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400; +} + +.site-title { + @apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl; +} + +.site-lead { + @apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg; +} + +.site-link-primary { + @apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400; +} + +.site-link-secondary { + @apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white; +} diff --git a/apps/storefront/src/app/layout.tsx b/apps/storefront/src/app/layout.tsx new file mode 100644 index 0000000..56f0218 --- /dev/null +++ b/apps/storefront/src/app/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from 'next' +import { cookies } from 'next/headers' +import MarketplaceShell from '@/components/MarketplaceShell' +import { getMarketplaceLanguage } from '@/lib/i18n.server' +import './globals.css' + +export const metadata: Metadata = { + title: 'FleetOS Marketplace', + description: 'Discover vehicles from trusted rental companies.', + icons: { + icon: '/rentalcardrive.png', + shortcut: '/favicon.ico', + apple: '/rentalcardrive.png', + }, +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const language = await getMarketplaceLanguage() + const cookieStore = await cookies() + const rawTheme = + cookieStore.get('rentaldrivego-theme')?.value ?? + cookieStore.get('marketplace-theme')?.value + const theme = rawTheme === 'dark' ? 'dark' : 'light' + + return ( + + + {/* Runs before hydration to prevent flash of wrong theme */} +