refactor: split marketplace into homepage and storefront apps
Build & Deploy / Build & Push Docker Image (push) Failing after 44s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m0s
Test / Marketplace Unit Tests (push) Failing after 4m51s
Test / Admin Unit Tests (push) Successful in 9m31s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

- Remove apps/marketplace entirely
- Create apps/homepage (port 3000): landing/marketing pages (home, features, pricing, platform-ops, review, legal)
- Create apps/storefront (port 3004): vehicle browsing (explore) and renter account (dashboard, profile, notifications)
- Duplicate shared components/libs into each app
- Update homepage header nav: Home, Features, Pricing instead of Home, Explore
- Fix all /explore cross-references in homepage to point to /features
- Update docker-compose.dev.yml: new homepage and storefront services, remove marketplace
- Update docker-compose.production.yml: split into homepage (main domain) and storefront (path-based routes)
- Update Dockerfiles: EXPOSE 3004 instead of 3003
- Update root package.json scripts: homepage/storefront profiles, tests, prod scripts
- Add docker-prod-up-homepage.sh and docker-prod-up-storefront.sh, remove marketplace script
This commit is contained in:
root
2026-06-25 21:09:16 -04:00
parent c5f614b3e4
commit 256ff0814e
134 changed files with 3223 additions and 38 deletions
@@ -0,0 +1,63 @@
'use client'
import { LucideIcon } from 'lucide-react'
interface Feature {
title: string
body: string
icon?: LucideIcon
}
interface FeatureAlternatingProps {
features: Feature[]
}
export function FeatureAlternating({ features }: FeatureAlternatingProps) {
return (
<div className="space-y-16 lg:space-y-20">
{features.map((feature, index) => {
const Icon = feature.icon
const isEven = index % 2 === 0
return (
<div
key={feature.title}
className={`grid gap-12 items-center ${isEven ? 'lg:grid-cols-[1.1fr_0.9fr]' : 'lg:grid-cols-[0.9fr_1.1fr]'}`}
>
<div className={isEven ? 'order-1 lg:order-none' : 'order-2 lg:order-none'}>
<div className="site-panel">
<div className="flex items-start gap-3">
{Icon && <Icon className="mt-1 h-6 w-6 shrink-0 text-orange-600 dark:text-orange-500" />}
<div>
<h2 className="text-2xl font-black text-blue-950 dark:text-white sm:text-3xl">
{feature.title}
</h2>
<p className="mt-4 text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
{feature.body}
</p>
</div>
</div>
</div>
</div>
<div
className={`order-2 lg:order-none ${isEven ? 'order-2 lg:order-none' : 'order-1 lg:order-none'}`}
>
<div className="site-panel-muted flex h-48 items-center justify-center sm:h-56 lg:h-80">
{Icon ? (
<Icon className="h-24 w-24 text-orange-600/20 dark:text-orange-500/20" />
) : (
<div className="text-center">
<p className="text-sm font-semibold text-stone-400 dark:text-stone-600">
{feature.title}
</p>
</div>
)}
</div>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,64 @@
import React, { isValidElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/MarketplaceShell', () => ({
useMarketplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
}))
import FooterContentPage from './FooterContentPage'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
describe('FooterContentPage', () => {
beforeEach(() => {
preferenceState.language = 'en'
})
it('uses the current marketplace language when no forced language is provided', () => {
preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).toContain('Besoin de plus de détails')
})
it('lets static app policy pages force their own locale', () => {
preferenceState.language = 'en'
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).not.toContain('Footer information')
})
it('marks Arabic content as right-aligned', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
const elements = collectElements(page)
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
})
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
const links = collectElements(page).filter((element) => element.type === 'a')
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
})
})
@@ -0,0 +1,107 @@
'use client'
import React from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type MarketplaceLanguage } from '@/lib/i18n'
const pageMeta = {
en: {
kicker: 'Footer information',
cta: 'Need more details?',
support: 'Contact the RentalDriveGo team through our official channels for business, support, or partnership requests.',
},
fr: {
kicker: 'Informations du pied de page',
cta: 'Besoin de plus de détails ?',
support: "Contactez l'équipe RentalDriveGo via nos canaux officiels pour toute demande commerciale, support ou partenariat.",
},
ar: {
kicker: 'معلومات التذييل',
cta: 'هل تحتاج إلى مزيد من التفاصيل؟',
support: 'تواصل مع فريق RentalDriveGo عبر القنوات الرسمية للاستفسارات التجارية أو الدعم أو الشراكات.',
},
} as const
const urlPattern = /(https?:\/\/[^\s]+)/g
function renderParagraphText(paragraph: string) {
const parts = paragraph.split(urlPattern)
return parts.map((part, index) => {
if (urlPattern.test(part)) {
urlPattern.lastIndex = 0
const match = part.match(/^(https?:\/\/[^\s]+?)([.,!?;:]*)$/)
const href = match?.[1] ?? part
const trailing = match?.[2] ?? ''
return (
<span key={`${part}-${index}`}>
<a
href={href}
className="text-orange-700 underline decoration-orange-300 underline-offset-4 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
>
{href}
</a>
{trailing}
</span>
)
}
urlPattern.lastIndex = 0
return <span key={`${part}-${index}`}>{part}</span>
})
}
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: MarketplaceLanguage }) {
const { language } = useMarketplacePreferences()
const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage]
const isArabic = activeLanguage === 'ar'
return (
<main className="site-page">
<div className="site-section mx-auto max-w-4xl">
<section className="site-panel overflow-hidden p-0">
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
<p className="site-kicker">
{meta.kicker}
</p>
<h1 className="site-title">
{content.title}
</h1>
</div>
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
{content.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
{content.sections?.map((section) => (
<section key={section.heading} className="space-y-4">
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
</section>
))}
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
{meta.cta}
</p>
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
</div>
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,75 @@
'use client'
import { useState } from 'react'
import { LucideIcon } from 'lucide-react'
interface Step {
number: string
title: string
description: string
icon?: LucideIcon
}
interface HowItWorksProps {
steps: Step[]
kicker?: string
title?: string
}
export function HowItWorks({ steps, kicker = 'GETTING STARTED', title = 'How It Works' }: HowItWorksProps) {
const [activeStep, setActiveStep] = useState(0)
return (
<section className="site-section">
<div className="site-panel">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">{title}</h2>
<div className="mt-12 space-y-4 lg:space-y-6">
{steps.map((step, index) => {
const Icon = step.icon
const isActive = activeStep === index
return (
<button
key={step.number}
onClick={() => setActiveStep(index)}
className={`w-full flex gap-6 rounded-[1.5rem] border p-6 transition-all sm:p-8 ${
isActive
? 'border-orange-600 bg-orange-50 shadow-lg dark:border-orange-500 dark:bg-orange-950/30'
: 'border-stone-200/80 bg-white/85 hover:-translate-y-1 dark:border-blue-800 dark:bg-blue-950/40'
}`}
>
<div className="flex shrink-0 flex-col items-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full font-bold text-white transition-all sm:h-14 sm:w-14 ${
isActive
? 'scale-110 bg-orange-600 shadow-lg dark:bg-orange-500'
: 'bg-orange-600 dark:bg-orange-500'
}`}
>
{step.number}
</div>
{index < steps.length - 1 && (
<div className="mt-4 h-8 w-1 bg-gradient-to-b from-orange-600 to-transparent dark:from-orange-500" />
)}
</div>
<div className="flex-1 pt-1 text-left">
<div className="flex items-start gap-3">
{Icon && (
<Icon className="mt-1 h-5 w-5 shrink-0 text-orange-600 dark:text-orange-400" />
)}
<div>
<h3 className="text-lg font-bold text-blue-950 dark:text-white">{step.title}</h3>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-stone-300">{step.description}</p>
</div>
</div>
</div>
</button>
)
})}
</div>
</div>
</section>
)
}
@@ -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/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 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-blue-900 dark:bg-blue-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-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 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()} FleetOS. {rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-blue-900 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,32 @@
import { describe, expect, it } from 'vitest'
import {
buildDashboardSignInHref,
companyInitial,
localeMenuPositionClass,
ownerWorkspaceHref,
} from './MarketplaceHeader'
describe('MarketplaceHeader helpers', () => {
it('builds dashboard sign-in links with language and theme query parameters', () => {
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
)
expect(buildDashboardSignInHref('/dashboard', 'ar', 'light')).toBe('/dashboard/sign-in?lang=ar&theme=light')
})
it('keeps the locale dropdown anchored to the readable side for RTL and LTR languages', () => {
expect(localeMenuPositionClass('ar')).toBe('right-0 sm:right-0')
expect(localeMenuPositionClass('en')).toBe('left-0 sm:left-auto sm:right-0')
expect(localeMenuPositionClass('fr')).toBe('left-0 sm:left-auto sm:right-0')
})
it('routes existing companies to their workspace and new owners to sign-up', () => {
expect(ownerWorkspaceHref('https://dashboard.example.com', 'Atlas Cars')).toBe('https://dashboard.example.com')
expect(ownerWorkspaceHref('https://dashboard.example.com', null)).toBe('https://dashboard.example.com/sign-up')
})
it('normalizes company initials for the owner workspace pill', () => {
expect(companyInitial('atlas cars')).toBe('A')
expect(companyInitial(' زاكورة كار ')).toBe('ز')
})
})
@@ -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<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: '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 (
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 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="FleetOS"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>FleetOS</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-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.home}
</Link>
<Link
href="/features"
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-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.features}
</Link>
<Link
href="/pricing"
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-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.pricing}
</Link>
<a
href={signInHref}
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-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.signIn}
</a>
{companyName ? (
<a
href={dashboardUrl}
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 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-blue-950/20">
{companyInitial(companyName)}
</span>
{companyName}
</a>
) : (
<a
href={ownerWorkspaceHref(dashboardUrl, companyName)}
className="ml-1 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
>
{dict.ownerSignIn}
</a>
)}
</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-blue-800 dark:bg-blue-950/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-blue-900/40 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-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{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-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 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-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white 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-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{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-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
)
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('MarketplaceShell auth helpers', () => {
it('does not report an employee profile when the browser cache is empty', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn(() => null),
},
})
expect(hasCachedEmployeeProfile()).toBe(false)
})
it('detects the cached employee profile marker written by dashboard sign-in', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
},
})
expect(hasCachedEmployeeProfile()).toBe(true)
})
it('clears the cached employee profile marker', () => {
const removeItem = vi.fn()
vi.stubGlobal('window', {
localStorage: {
removeItem,
},
})
clearCachedEmployeeProfile()
expect(removeItem).toHaveBeenCalledWith('employee_profile')
})
})
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './MarketplaceShell'
describe('MarketplaceShell footer content registry', () => {
it('exposes exactly the supported marketplace locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
})
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
const content = getFooterContent('en')
expect(content.localeLabel).toBe('Global (English)')
expect(content.rightsLabel).toBe('All rights reserved.')
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/app-privacy-en' })
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
})
it('returns French labels while preserving canonical footer slugs', () => {
const content = getFooterContent('fr')
expect(content.localeLabel).toBe('Europe (French)')
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/footer/terms-of-service' })
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
})
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
const content = getFooterContent('ar')
expect(content.localeLabel).toBe('North Africa (Arabic)')
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/app-privacy-ar' })
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
})
})
@@ -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<MarketplaceLanguage, Dictionary> = {
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<PreferencesContextValue | null>(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<MarketplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(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 <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
}
@@ -0,0 +1,45 @@
'use client'
import { useState } from 'react'
export function PricingToggle({
onToggle,
}: {
onToggle: (isAnnual: boolean) => void
}) {
const [isAnnual, setIsAnnual] = useState(false)
const handleToggle = () => {
const newValue = !isAnnual
setIsAnnual(newValue)
onToggle(newValue)
}
return (
<div className="flex justify-center">
<div className="inline-flex items-center gap-4 rounded-full border border-stone-200 bg-white/80 p-1 dark:border-blue-800 dark:bg-blue-950/40">
<button
onClick={() => !isAnnual && handleToggle()}
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
!isAnnual
? 'bg-orange-600 text-white dark:bg-orange-500'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
}`}
>
Monthly
</button>
<button
onClick={() => isAnnual && handleToggle()}
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
isAnnual
? 'bg-orange-600 text-white dark:bg-orange-500'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
}`}
>
Annual
<span className="ml-2 text-xs font-bold text-orange-200 dark:text-orange-300">-20%</span>
</button>
</div>
</div>
)
}
@@ -0,0 +1,86 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface Stat {
label: string
value: number
suffix?: string
}
interface StatsStripProps {
stats: Stat[]
kicker?: string
}
function Counter({ value, suffix = '' }: { value: number; suffix?: string }) {
const [count, setCount] = useState(0)
const ref = useRef<HTMLParagraphElement>(null)
const hasAnimated = useRef(false)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !hasAnimated.current) {
hasAnimated.current = true
const duration = 2000
const steps = 60
const increment = value / steps
let current = 0
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
current = Math.floor(value * progress)
setCount(current)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
animate()
}
},
{ threshold: 0.1 }
)
if (ref.current) {
observer.observe(ref.current)
}
return () => {
if (ref.current) {
observer.unobserve(ref.current)
}
}
}, [value])
return (
<p ref={ref} className="text-3xl font-black text-blue-950 dark:text-white sm:text-4xl">
{count.toLocaleString()}
{suffix}
</p>
)
}
export function StatsStrip({ stats, kicker = 'BY THE NUMBERS' }: StatsStripProps) {
return (
<section className="site-section">
<div className="site-panel">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">Our Impact</h2>
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex flex-col">
<Counter value={stat.value} suffix={stat.suffix} />
<p className="mt-4 text-sm font-semibold text-stone-600 dark:text-stone-300">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,116 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface Testimonial {
quote: string
author: string
role: string
company?: string
image?: string
}
interface TestimonialsSectionProps {
testimonials: Testimonial[]
kicker?: string
title?: string
}
export function TestimonialsSection({
testimonials,
kicker = 'WHAT CUSTOMERS SAY',
title = 'Testimonials',
}: TestimonialsSectionProps) {
const [activeIndex, setActiveIndex] = useState(0)
const autoScrollRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
autoScrollRef.current = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % testimonials.length)
}, 5000)
return () => {
if (autoScrollRef.current) clearInterval(autoScrollRef.current)
}
}, [testimonials.length])
const handlePrev = () => {
setActiveIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length)
}
const handleNext = () => {
setActiveIndex((prev) => (prev + 1) % testimonials.length)
}
if (!testimonials || testimonials.length === 0) return null
return (
<section className="site-section">
<div className="flex flex-col">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">{title}</h2>
<div className="mt-12 relative">
<div className="site-panel">
<div className="flex items-start gap-4">
<div className="flex-1">
<p className="text-lg leading-8 text-blue-950 dark:text-stone-200 sm:text-xl">
"{testimonials[activeIndex].quote}"
</p>
<div className="mt-8 flex flex-col gap-2">
<p className="font-bold text-blue-950 dark:text-white">{testimonials[activeIndex].author}</p>
<p className="text-sm text-stone-600 dark:text-stone-400">
{testimonials[activeIndex].role}
{testimonials[activeIndex].company && ` at ${testimonials[activeIndex].company}`}
</p>
</div>
</div>
{testimonials[activeIndex].image && (
<img
src={testimonials[activeIndex].image}
alt={testimonials[activeIndex].author}
className="h-16 w-16 rounded-full object-cover"
/>
)}
</div>
</div>
{testimonials.length > 1 && (
<div className="mt-6 flex justify-center gap-4">
<button
onClick={handlePrev}
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
aria-label="Previous testimonial"
>
Prev
</button>
<div className="flex items-center gap-2">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`h-2 w-2 rounded-full transition ${
index === activeIndex ? 'bg-orange-600 dark:bg-orange-500' : 'bg-stone-300 dark:bg-stone-600'
}`}
aria-label={`Go to testimonial ${index + 1}`}
/>
))}
</div>
<button
onClick={handleNext}
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
aria-label="Next testimonial"
>
Next
</button>
</div>
)}
</div>
</div>
</section>
)
}
@@ -0,0 +1,234 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import Image from 'next/image'
import Link from 'next/link'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function WorkspaceTabs() {
const { language, theme } = useMarketplacePreferences()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const copy = {
en: {
badge: 'Unified Workspace',
title: 'One launch point on port 3000 for marketplace discovery, company operations, and platform admin work.',
intro:
'The apps still run as separate Next.js surfaces internally, but the navbar now takes you straight to each workspace area without hunting for ports.',
sectionBadge: 'RentalDriveGo Marketplace',
sectionTitle: 'Browse local rental fleets without losing the company behind the wheel.',
sectionBodyA: 'Discovery happens here. Booking and payment happen on each rental company\'s own branded site.',
sectionBodyB: 'Customers only reach that branded booking site after selecting a vehicle from a company\'s fleet.',
exploreVehicles: 'Explore vehicles',
viewPricing: 'View pricing',
nowServing: 'Now serving',
promoTitle: 'Discovery, pricing, owner sign-in, and booking entry all stay anchored on the marketplace surface.',
promoBody:
'Company Workspace and Platform Operations stay available from the navbar, while branded booking starts after a renter picks a vehicle.',
customerPaths: 'Customer Paths',
ownerSignIn: 'Owner sign in',
pricing: 'Pricing',
workspaceAreas: 'Workspace Areas',
workspaceA: 'Company Workspace for fleet operations, team, billing, and analytics.',
workspaceB: 'Platform Operations for platform-level support and tenant oversight.',
workspaceC: 'Branded booking handoff begins only after a renter chooses a specific vehicle.',
},
fr: {
badge: 'Espace unifié',
title: 'Un point dentrée sur le port 3000 pour la découverte marketplace, les opérations de lentreprise et ladministration de la plateforme.',
intro:
'Les applications restent des interfaces Next.js séparées en interne, mais la barre de navigation mène directement à chaque espace sans avoir à changer de port manuellement.',
sectionBadge: 'Marketplace RentalDriveGo',
sectionTitle: 'Parcourez les flottes locales sans perdre lidentité de lentreprise qui gère la location.',
sectionBodyA: 'La découverte commence ici. La réservation et le paiement se font sur le site de réservation propre à chaque entreprise.',
sectionBodyB: 'Les clients accèdent à ce site de réservation seulement après avoir choisi un véhicule dans la flotte dune entreprise.',
exploreVehicles: 'Explorer les véhicules',
viewPricing: 'Voir les tarifs',
nowServing: 'Disponible',
promoTitle: 'La découverte, les tarifs, la connexion propriétaire et laccès à la réservation restent ancrés dans la marketplace.',
promoBody:
'Lespace entreprise et les opérations de la plateforme restent accessibles depuis la navigation, tandis que la réservation de marque commence après le choix dun véhicule.',
customerPaths: 'Parcours client',
ownerSignIn: 'Connexion propriétaire',
pricing: 'Tarifs',
workspaceAreas: 'Espaces de travail',
workspaceA: 'Espace entreprise pour la flotte, l’équipe, la facturation et lanalyse.',
workspaceB: 'Opérations plateforme pour le support global et la supervision multi-entreprises.',
workspaceC: 'Le transfert vers la réservation de marque commence seulement après le choix dun véhicule précis.',
},
ar: {
badge: 'مساحة موحدة',
title: 'نقطة دخول واحدة على المنفذ 3000 لاكتشاف السوق وعمليات الشركات وإدارة المنصة.',
intro:
'ما زالت التطبيقات تعمل كواجهات Next.js منفصلة داخلياً، لكن شريط التنقل ينقلك مباشرة إلى كل مساحة عمل من دون الحاجة إلى البحث عن المنافذ.',
sectionBadge: 'سوق RentalDriveGo',
sectionTitle: 'تصفح أساطيل التأجير المحلية مع الحفاظ على هوية الشركة التي تدير الحجز.',
sectionBodyA: 'الاكتشاف يبدأ هنا. الحجز والدفع يتمان على موقع الحجز الخاص بكل شركة.',
sectionBodyB: 'لا يصل العميل إلى موقع الحجز الخاص بالشركة إلا بعد اختيار سيارة من أسطول تلك الشركة.',
exploreVehicles: 'استكشف السيارات',
viewPricing: 'عرض الأسعار',
nowServing: 'المتاح الآن',
promoTitle: 'الاكتشاف والأسعار وتسجيل دخول المالك وبداية الحجز تبقى كلها داخل واجهة السوق.',
promoBody:
'تبقى مساحة الشركة وعمليات المنصة متاحتين من شريط التنقل، بينما يبدأ الحجز المرتبط بالعلامة التجارية بعد اختيار المستأجر لسيارة.',
customerPaths: 'مسارات العملاء',
ownerSignIn: 'دخول المالك',
pricing: 'الأسعار',
workspaceAreas: 'مساحات العمل',
workspaceA: 'مساحة الشركة لإدارة الأسطول والفريق والفوترة والتحليلات.',
workspaceB: 'عمليات المنصة للدعم والإشراف على الشركات على مستوى المنصة.',
workspaceC: 'الانتقال إلى موقع الحجز الخاص بالشركة يبدأ فقط بعد اختيار سيارة محددة.',
},
}[language]
return (
<main className={`min-h-screen transition-colors ${
theme === 'dark'
? 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.20),transparent_28%),linear-gradient(180deg,#0f1f4a,#112d6e)]'
: 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.08),transparent_28%),linear-gradient(180deg,#f5f8ff,white)]'
}`}>
<div className="shell py-16 sm:py-20">
<div className="max-w-4xl">
<div className="flex items-center gap-4">
<Link href="/" aria-label="RentalDriveGo home">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={72}
height={72}
className={`h-16 w-16 rounded-2xl object-contain p-1 shadow-sm ${
theme === 'dark' ? 'border border-orange-500/40 bg-blue-950' : 'border border-orange-200 bg-white'
}`}
/>
</Link>
<p className={`text-sm font-semibold uppercase tracking-[0.24em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.badge}
</p>
</div>
<h1 className={`mt-5 text-4xl font-black tracking-tight sm:text-6xl ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.title}
</h1>
<p className={`mt-6 max-w-3xl text-lg leading-8 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.intro}
</p>
</div>
<section className={`mt-10 overflow-hidden rounded-[2rem] border shadow-xl transition-colors ${
theme === 'dark'
? 'border-blue-900 bg-blue-950 shadow-black/20'
: 'border-stone-200 bg-white shadow-stone-200/40'
}`}>
<div className={`px-6 py-6 ${theme === 'dark' ? 'border-b border-stone-800' : 'border-b border-stone-200'}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.sectionBadge}
</p>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<h2 className={`text-2xl font-black tracking-tight ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.sectionTitle}
</h2>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyA}
</p>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyB}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/features"
className={`rounded-full px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'bg-orange-400 text-blue-950 hover:bg-orange-300'
: 'bg-blue-950 text-white hover:bg-blue-900/40'
}`}
>
{copy.exploreVehicles}
</Link>
<Link
href="/pricing"
className={`rounded-full border px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'border-blue-800 text-slate-200 hover:border-blue-600 hover:bg-blue-900/40'
: 'border-stone-300 text-stone-700 hover:border-stone-400 hover:bg-stone-50'
}`}
>
{copy.viewPricing}
</Link>
</div>
</div>
</div>
<div className={`grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr_0.9fr] ${
theme === 'dark' ? 'bg-blue-950' : 'bg-stone-50'
}`}>
<div className="rounded-[1.5rem] bg-[linear-gradient(135deg,#06132e,#0d1b38)] p-8 text-white">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-300">{copy.nowServing}</p>
<h3 className="mt-4 text-3xl font-black tracking-tight">
{copy.promoTitle}
</h3>
<p className="mt-4 max-w-2xl text-sm leading-7 text-stone-200">
{copy.promoBody}
</p>
</div>
<div className="grid gap-4">
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.customerPaths}</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link href="/features" className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white">
{copy.exploreVehicles}
</Link>
<a className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href={`${dashboardUrl}/sign-in`}>
{copy.ownerSignIn}
</a>
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href="/pricing">
{copy.pricing}
</Link>
</div>
</div>
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.workspaceAreas}</p>
<ul className={`mt-4 space-y-3 text-sm ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
<li>{copy.workspaceA}</li>
<li>{copy.workspaceB}</li>
<li>{copy.workspaceC}</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</main>
)
}