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
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:
@@ -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">
|
||||
© {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 d’entrée sur le port 3000 pour la découverte marketplace, les opérations de l’entreprise et l’administration 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 l’identité de l’entreprise 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 d’une entreprise.',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
viewPricing: 'Voir les tarifs',
|
||||
nowServing: 'Disponible',
|
||||
promoTitle: 'La découverte, les tarifs, la connexion propriétaire et l’accès à la réservation restent ancrés dans la marketplace.',
|
||||
promoBody:
|
||||
'L’espace 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 d’un 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 l’analyse.',
|
||||
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 d’un 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user