6913c298ad
Build & Deploy / Build & Push Docker Image (push) Successful in 2m57s
Test / Type Check (all packages) (push) Successful in 57s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m4s
Replace the legacy /explore marketplace route with /storefront across the storefront app and production routing. - move the marketplace index page to the storefront root route - move company and vehicle detail pages from /explore/* to /* internally - update marketplace links, renter dashboard links, saved company links, and header navigation to target /storefront - remove remaining explore route references and wording from storefront code - configure production Traefik to route /storefront and strip the prefix before forwarding to the storefront service - set the storefront production asset prefix to /storefront so Next chunks load from /storefront/_next instead of the shared root /_next path - redirect locale entry paths such as /storefront/fr back to /storefront while persisting the selected language cookie - update middleware tests for the locale redirect and adjusted redirect mock Verification: - npm run test --workspace @rentaldrivego/storefront - npm run build --workspace @rentaldrivego/storefront
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
'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
|
|
storefront: 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 StorefrontHeader({
|
|
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="/rentaldrivego.png"
|
|
alt="RentalDriveGo"
|
|
width={36}
|
|
height={36}
|
|
priority
|
|
unoptimized
|
|
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
|
|
/>
|
|
<span>RentalDriveGo</span>
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
|
|
<nav className="flex items-center gap-0.5 overflow-x-auto">
|
|
<Link
|
|
href="/"
|
|
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
|
|
>
|
|
{dict.home}
|
|
</Link>
|
|
<Link
|
|
href="/storefront"
|
|
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.storefront}
|
|
</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>
|
|
)
|
|
}
|