fix notification and add billing page and contract

This commit is contained in:
root
2026-05-13 00:09:39 -04:00
committed by Administrator
parent 1a39aa8433
commit 89621a163b
52 changed files with 5631 additions and 1110 deletions
@@ -1,14 +1,5 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function CompanyWorkspacePage() {
const requestHeaders = headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/dashboard`)
redirect('/sign-in')
}
+120
View File
@@ -13,3 +13,123 @@ body {
.card {
@apply rounded-2xl border border-stone-200 bg-white shadow-sm transition-colors dark:border-stone-800 dark:bg-stone-900;
}
body[data-theme='medium'] {
background-color: rgb(41 37 36);
color: rgb(231 229 228);
}
body[data-theme='medium'] .dark\:bg-stone-950 {
background-color: rgb(41 37 36);
}
body[data-theme='medium'] .dark\:bg-stone-950\/80 {
background-color: rgb(41 37 36 / 0.8);
}
body[data-theme='medium'] .dark\:bg-stone-950\/95 {
background-color: rgb(41 37 36 / 0.95);
}
body[data-theme='medium'] .dark\:bg-stone-950\/50 {
background-color: rgb(68 64 60 / 0.5);
}
body[data-theme='medium'] .dark\:bg-stone-950\/40 {
background-color: rgb(68 64 60 / 0.4);
}
body[data-theme='medium'] .dark\:bg-stone-950\/30 {
background-color: rgb(68 64 60 / 0.3);
}
body[data-theme='medium'] .dark\:bg-stone-900,
body[data-theme='medium'] .dark\:bg-stone-900\/80,
body[data-theme='medium'] .dark\:bg-stone-900\/70,
body[data-theme='medium'] .dark\:bg-stone-900\/60 {
background-color: rgb(68 64 60 / 0.85);
}
body[data-theme='medium'] .dark\:bg-stone-800 {
background-color: rgb(87 83 78);
}
body[data-theme='medium'] .dark\:bg-amber-400 {
background-color: rgb(251 191 36);
}
body[data-theme='medium'] .dark\:border-stone-800 {
border-color: rgb(120 113 108);
}
body[data-theme='medium'] .dark\:border-stone-700,
body[data-theme='medium'] .dark\:border-white\/5 {
border-color: rgb(87 83 78);
}
body[data-theme='medium'] .dark\:border-amber-300 {
border-color: rgb(252 211 77);
}
body[data-theme='medium'] .dark\:text-stone-50,
body[data-theme='medium'] .dark\:text-stone-100,
body[data-theme='medium'] .dark\:text-white {
color: rgb(250 250 249);
}
body[data-theme='medium'] .dark\:text-stone-200 {
color: rgb(231 229 228);
}
body[data-theme='medium'] .dark\:text-stone-300,
body[data-theme='medium'] .dark\:text-stone-400 {
color: rgb(214 211 209);
}
body[data-theme='medium'] .dark\:text-stone-500,
body[data-theme='medium'] .dark\:text-stone-700,
body[data-theme='medium'] .dark\:text-stone-800 {
color: rgb(168 162 158);
}
body[data-theme='medium'] .dark\:text-stone-950 {
color: rgb(28 25 23);
}
body[data-theme='medium'] .dark\:text-slate-100 {
color: rgb(250 250 249);
}
body[data-theme='medium'] .dark\:text-stone-600 {
color: rgb(168 162 158);
}
body[data-theme='medium'] .dark\:text-amber-300 {
color: rgb(252 211 77);
}
body[data-theme='medium'] .dark\:text-sky-400 {
color: rgb(125 211 252);
}
body[data-theme='medium'] .dark\:hover\:bg-stone-800:hover,
body[data-theme='medium'] .dark\:hover\:bg-stone-900:hover {
background-color: rgb(28 25 23);
}
body[data-theme='medium'] .dark\:hover\:bg-amber-300:hover {
background-color: rgb(252 211 77);
}
body[data-theme='medium'] .dark\:hover\:text-stone-100:hover,
body[data-theme='medium'] .dark\:hover\:text-white:hover {
color: rgb(250 250 249);
}
body[data-theme='medium'] .dark\:hover\:border-stone-200:hover {
border-color: rgb(214 211 209);
}
body[data-theme='medium'] .dark\:bg-\[\#111111\] {
background-color: rgb(28 25 23);
}
@@ -1,14 +1,5 @@
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default function PlatformOperationsPage() {
const requestHeaders = headers()
const adminUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${adminUrl}/dashboard`)
redirect('/sign-in')
}
@@ -143,74 +143,54 @@ export default function RenterDashboardPage() {
})
}, [router])
function handleLogout() {
localStorage.removeItem('renter_token')
router.push('/explore')
}
// Loading skeleton
if (status === 'loading') {
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell">
<div className="animate-pulse space-y-6">
<div className="h-8 w-48 rounded-xl bg-stone-200" />
<div className="h-40 rounded-2xl bg-stone-200" />
<div className="h-40 rounded-2xl bg-stone-200" />
</div>
<div className="shell">
<div className="animate-pulse space-y-6">
<div className="h-8 w-48 rounded-xl bg-stone-200" />
<div className="h-40 rounded-2xl bg-stone-200" />
<div className="h-40 rounded-2xl bg-stone-200" />
</div>
</main>
</div>
)
}
if (status === 'error') {
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell">
<div className="card p-8 text-center">
<p className="text-sm text-rose-600">{errorMsg}</p>
<button
onClick={handleLogout}
className="mt-4 rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white"
>
{dict.backToExplore}
</button>
</div>
<div className="shell">
<div className="card p-8 text-center">
<p className="text-sm text-rose-600">{errorMsg}</p>
<Link
href="/explore"
className="mt-4 inline-flex rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white"
>
{dict.backToExplore}
</Link>
</div>
</main>
</div>
)
}
const savedCompanies = profile?.savedCompanies ?? []
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell space-y-8">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
{dict.renterDashboard}
</p>
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">
{dict.welcome} {profile?.firstName}
</h1>
</div>
<div className="flex items-center gap-3">
<Link
href="/explore"
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300"
>
{dict.exploreVehicles}
</Link>
<button
onClick={handleLogout}
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-700"
>
{dict.logout}
</button>
</div>
<div className="shell space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">
{dict.renterDashboard}
</p>
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">
{dict.welcome} {profile?.firstName}
</h1>
</div>
<Link
href="/explore"
className="self-start rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 shadow-sm hover:border-stone-300"
>
{dict.exploreVehicles}
</Link>
</div>
<section className="grid gap-4 sm:grid-cols-3">
{[
@@ -325,7 +305,6 @@ export default function RenterDashboardPage() {
</div>
)}
</section>
</div>
</main>
</div>
)
}
@@ -0,0 +1,5 @@
import RenterShell from '@/components/RenterShell'
export default function RenterLayout({ children }: { children: React.ReactNode }) {
return <RenterShell>{children}</RenterShell>
}
@@ -1,7 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
RenterAuthError,
@@ -101,64 +100,54 @@ export default function RenterNotificationsPage() {
}
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter}</p>
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={markAllRead}
disabled={loading || updating}
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700"
>
{dict.markAll}
</button>
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700">
{dict.back}
</Link>
</div>
</div>
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
{!loading && !errorMsg && notifications.length === 0 ? (
<div className="card p-10 text-center text-sm text-stone-500">{dict.empty}</div>
) : null}
{!loading && !errorMsg && notifications.length > 0 ? (
<div className="space-y-4">
{notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">{notification.type.replaceAll('_', ' ')}</p>
<h2 className="mt-2 text-lg font-bold text-stone-900">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">{dict.read}</span>
) : (
<button
type="button"
onClick={() => markRead(notification.id)}
disabled={updating}
className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800"
>
{dict.markRead}
</button>
)}
</div>
<p className="mt-3 text-sm leading-7 text-stone-600">{notification.body}</p>
<p className="mt-4 text-xs text-stone-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article>
))}
</div>
) : null}
<div className="shell space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
<button
type="button"
onClick={markAllRead}
disabled={loading || updating}
className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700"
>
{dict.markAll}
</button>
</div>
</main>
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
{!loading && !errorMsg && notifications.length === 0 ? (
<div className="card p-10 text-center text-sm text-stone-500">{dict.empty}</div>
) : null}
{!loading && !errorMsg && notifications.length > 0 ? (
<div className="space-y-4">
{notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">{notification.type.replaceAll('_', ' ')}</p>
<h2 className="mt-2 text-lg font-bold text-stone-900">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">{dict.read}</span>
) : (
<button
type="button"
onClick={() => markRead(notification.id)}
disabled={updating}
className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800"
>
{dict.markRead}
</button>
)}
</div>
<p className="mt-3 text-sm leading-7 text-stone-600">{notification.body}</p>
<p className="mt-4 text-xs text-stone-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article>
))}
</div>
) : null}
</div>
)
}
@@ -1,7 +1,6 @@
'use client'
import { useEffect, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
RenterAuthError,
@@ -136,15 +135,15 @@ export default function RenterProfilePage() {
}, [router])
if (status === 'loading') {
return <RenterShell title={dict.profile} dict={dict}><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></RenterShell>
return <div className="shell"><div className="card p-8 text-sm text-stone-500">{dict.loading}</div></div>
}
if (status === 'error') {
return <RenterShell title={dict.profile} dict={dict}><ErrorCard message={errorMsg} /></RenterShell>
return <div className="shell"><ErrorCard message={errorMsg} /></div>
}
return (
<RenterShell title={dict.profile} dict={dict}>
<div className="shell space-y-6">
<div className="card p-8">
<h1 className="text-3xl font-black tracking-tight text-stone-900">{dict.yourProfile}</h1>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@@ -265,39 +264,7 @@ export default function RenterProfilePage() {
</table>
</div>
</div>
</RenterShell>
)
}
function RenterShell({ title, children, dict }: { title: string; children: ReactNode; dict: Record<string, string> }) {
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
{dict.back}
</Link>
<Link href="/renter/saved-companies" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
{dict.savedCompanies}
</Link>
<Link href="/renter/notifications" className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
{dict.notifications}
</Link>
<button
type="button"
onClick={() => {
clearRenterSession()
window.location.href = '/renter/sign-in'
}}
className="rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white"
>
{dict.logout}
</button>
</div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter} {title}</p>
{children}
</div>
</main>
</div>
)
}
@@ -59,55 +59,45 @@ export default function RenterSavedCompaniesPage() {
}, [router])
return (
<main className="min-h-screen bg-stone-50 py-10">
<div className="shell space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{dict.renter}</p>
<h1 className="mt-1 text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
</div>
<Link href="/renter/dashboard" className="rounded-full border border-stone-200 bg-white px-5 py-2.5 text-sm font-semibold text-stone-700">
{dict.back}
<div className="shell space-y-6">
<h1 className="text-3xl font-black tracking-tight text-stone-900">{dict.title}</h1>
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
{!loading && !errorMsg && companies.length === 0 ? (
<div className="card p-10 text-center">
<p className="text-sm text-stone-500">{dict.empty}</p>
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white">
{dict.browse}
</Link>
</div>
) : null}
{loading ? <div className="card p-8 text-sm text-stone-500">{dict.loading}</div> : null}
{!loading && errorMsg ? <div className="card p-8 text-sm text-rose-600">{errorMsg}</div> : null}
{!loading && !errorMsg && companies.length === 0 ? (
<div className="card p-10 text-center">
<p className="text-sm text-stone-500">{dict.empty}</p>
<Link href="/explore" className="mt-5 inline-flex rounded-full bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white">
{dict.browse}
</Link>
</div>
) : null}
{!loading && !errorMsg && companies.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{companies.map((company) => (
<Link
key={company.id}
href={`/explore/${company.brand?.subdomain ?? company.id}`}
className="card flex items-center gap-4 p-5 hover:border-amber-300 transition-colors"
>
{company.brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={company.brand.logoUrl} alt={company.brand.displayName} className="h-11 w-11 rounded-xl object-contain" />
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100 text-sm font-bold text-amber-700">
{company.brand?.displayName?.charAt(0) ?? '?'}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-stone-900">{company.brand?.displayName ?? dict.rentalCompany}</p>
<p className="mt-1 truncate text-xs text-stone-400">{company.brand?.subdomain ?? company.id}</p>
{!loading && !errorMsg && companies.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{companies.map((company) => (
<Link
key={company.id}
href={`/explore/${company.brand?.subdomain ?? company.id}`}
className="card flex items-center gap-4 p-5 hover:border-amber-300 transition-colors"
>
{company.brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={company.brand.logoUrl} alt={company.brand.displayName} className="h-11 w-11 rounded-xl object-contain" />
) : (
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-amber-100 text-sm font-bold text-amber-700">
{company.brand?.displayName?.charAt(0) ?? '?'}
</div>
</Link>
))}
</div>
) : null}
</div>
</main>
)}
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-stone-900">{company.brand?.displayName ?? dict.rentalCompany}</p>
<p className="mt-1 truncate text-xs text-stone-400">{company.brand?.subdomain ?? company.id}</p>
</div>
</Link>
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,5 @@
import WorkspaceFrame from '@/components/WorkspaceFrame'
export default function SignInPage() {
return <WorkspaceFrame target="sign-in" />
}
@@ -1,61 +1,63 @@
'use client'
import { ChevronDown, Globe } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { MARKETPLACE_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
type Theme = 'light' | 'medium' | 'dark'
type Dictionary = {
marketplace: string
companyWorkspace: string
platformOperations: string
explore: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
medium: string
dark: string
preferences: string
}
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
en: {
marketplace: 'Marketplace',
companyWorkspace: 'Company Workspace',
platformOperations: 'Platform Operations',
explore: 'Explore',
ownerSignIn: 'Owner sign in',
marketplace: 'Home',
explore: 'Marketplace',
signIn: 'Sign in',
ownerSignIn: 'Create account',
language: 'Language',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
preferences: 'Marketplace preferences',
},
fr: {
marketplace: 'Marketplace',
companyWorkspace: 'Espace entreprise',
platformOperations: 'Opérations plateforme',
explore: 'Explorer',
ownerSignIn: 'Connexion proprietaire',
marketplace: 'Accueil',
explore: 'Marketplace',
signIn: 'Connexion',
ownerSignIn: 'Créer un compte',
language: 'Langue',
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
preferences: 'Préférences marketplace',
},
ar: {
marketplace: 'السوق',
companyWorkspace: 'مساحة الشركة',
platformOperations: 'عمليات المنصة',
explore: 'استكشاف',
ownerSignIn: 'دخول المالك',
marketplace: 'الرئيسية',
explore: 'السوق',
signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء حساب',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
medium: 'متوسط',
dark: 'داكن',
preferences: 'تفضيلات السوق',
},
@@ -71,6 +73,88 @@ type PreferencesContextValue = {
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
type FooterItem = {
label: string
href?: string
}
const localeOptions: Array<{ value: MarketplaceLanguage; label: string }> = [
{ value: 'en', label: 'Global (English)' },
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
{ value: 'fr', label: 'Europ (French)' },
]
function getFooterContent(language: MarketplaceLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos', href: '/' },
{ label: 'CLUF' },
{ label: "Conditions d'utilisation" },
{ label: 'Sécurité' },
{ label: 'Conformité' },
{ label: 'Politique de confidentialité' },
{ label: 'Politique de cookies' },
{ label: "Programme d'affiliation" },
],
secondary: [
{ label: 'Newsletter' },
{ label: 'Contacter les ventes', href: `${resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')}/sign-in` },
{ label: 'Nos bureaux', href: '/' },
],
localeLabel: 'Europ (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: '/' },
{ label: 'اتفاقية الترخيص' },
{ label: 'شروط الخدمة' },
{ label: 'الأمان' },
{ label: 'الامتثال' },
{ label: 'سياسة الخصوصية' },
{ label: 'سياسة ملفات الارتباط' },
{ label: 'برنامج الشركاء' },
],
secondary: [
{ label: 'النشرة البريدية' },
{ label: 'تواصل مع المبيعات', href: `${resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')}/sign-in` },
{ label: 'مكاتبنا', href: '/' },
],
localeLabel: 'NorthAfrica (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About us', href: '/' },
{ label: 'EULA' },
{ label: 'Terms of service' },
{ label: 'Security' },
{ label: 'Compliance' },
{ label: 'Privacy policy' },
{ label: 'Cookie policy' },
{ label: 'Affiliate program' },
],
secondary: [
{ label: 'Newsletter' },
{ label: 'Contact sales', href: `${resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')}/sign-in` },
{ label: 'Our offices', href: '/' },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
function SegmentedControl<T extends string>({
label,
value,
@@ -126,44 +210,110 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
const [theme, setTheme] = useState<Theme>('light')
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>('en')
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [companyName, setCompanyName] = useState<string | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
async function syncCompanyBrand() {
const token = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('employee_token='))
?.split('=')[1]
if (!token) {
setCompanyName(null)
return
}
try {
const response = await fetch(`${apiUrl}/companies/me/brand`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) {
setCompanyName(null)
return
}
const json = await response.json()
const name = json?.data?.displayName ?? json?.displayName ?? null
setCompanyName(name)
} catch {
setCompanyName(null)
}
}
function syncScopedPreferencesForSession() {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguage(scopedLanguage)
} else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
}
if ((scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') && scopedTheme !== theme) {
setTheme(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}
useEffect(() => {
const storedLanguage = window.localStorage.getItem('marketplace-language')
const storedTheme = window.localStorage.getItem('marketplace-theme') as Theme | null
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (isMarketplaceLanguage(storedLanguage)) {
setLanguage(storedLanguage)
}
if (storedTheme === 'light' || storedTheme === 'dark') {
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
setTheme(storedTheme)
}
setHydrated(true)
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('employee_token='))?.split('=')[1]
if (token) {
fetch(`${apiUrl}/companies/me/brand`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(json => {
const name = json?.data?.displayName ?? json?.displayName
if (name) setCompanyName(name)
})
.catch(() => {})
}
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'
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
document.body.dataset.theme = theme
window.localStorage.setItem('marketplace-language', language)
window.localStorage.setItem('marketplace-theme', theme)
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
document.cookie = `${MARKETPLACE_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
if (hydrated && previousLanguage.current !== language) {
router.refresh()
@@ -171,12 +321,23 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
previousLanguage.current = language
}, [hydrated, language, router, theme])
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
const dict = dictionaries[language]
const isEmbeddedWorkspace =
pathname === '/company-workspace' ||
pathname === '/platform-operations' ||
pathname === '/branded-website'
const hideHeader = pathname === '/company-workspace'
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const isRenterApp = pathname.startsWith('/renter/')
const isEmbeddedWorkspace = pathname === '/branded-website' || isRenterApp
const hideHeader = isRenterApp
const value = useMemo(
() => ({ language, theme, dict, setLanguage, setTheme }),
[dict, language, theme],
@@ -209,24 +370,18 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
>
{dict.marketplace}
</Link>
<Link
href={`${dashboardUrl}/dashboard`}
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.companyWorkspace}
</Link>
<Link
href={`${adminUrl}/dashboard`}
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.platformOperations}
</Link>
<Link
href="/explore"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.explore}
</Link>
<Link
href="/sign-in"
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
{dict.signIn}
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
@@ -239,13 +394,23 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-in`}
href={`${dashboardUrl}/sign-up`}
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{dict.ownerSignIn}
</Link>
)}
</nav>
<SegmentedControl
label={dict.theme}
value={theme}
options={[
{ value: 'light', label: dict.light },
{ value: 'medium', label: dict.medium },
{ value: 'dark', label: dict.dark },
]}
onChange={setTheme}
/>
</div>
</div>
</header>
@@ -257,32 +422,62 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
</div>
{!isEmbeddedWorkspace ? (
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/90">
<div className="shell flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<SegmentedControl
label={dict.language}
value={language}
options={[
{ value: 'en', label: 'EN' },
{ value: 'fr', label: 'FR' },
{ value: 'ar', label: 'AR' },
]}
onChange={setLanguage}
/>
<SegmentedControl
label={dict.theme}
value={theme}
options={[
{ value: 'light', label: dict.light },
{ value: 'dark', label: dict.dark },
]}
onChange={setTheme}
/>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] 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">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.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">
{footerContent.secondary.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"
>
<Globe className="h-4 w-4" />
<span>{footerContent.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-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(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-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
) : null}
@@ -291,3 +486,17 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
</PreferencesContext.Provider>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 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,223 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import {
LayoutDashboard,
User,
Bookmark,
Bell,
LogOut,
ChevronRight,
ChevronLeft,
Car,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { clearRenterSession, loadRenterProfile } from '@/lib/renter'
const NAV_ITEMS = [
{ href: '/renter/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/renter/profile', key: 'profile', icon: User },
{ href: '/renter/saved-companies', key: 'savedCompanies', icon: Bookmark },
{ href: '/renter/notifications', key: 'notifications', icon: Bell },
] as const
const dicts = {
en: {
dashboard: 'Dashboard',
profile: 'Profile',
savedCompanies: 'Saved companies',
notifications: 'Notifications',
signOut: 'Sign out',
renterPortal: 'Renter Portal',
rights: 'All rights reserved.',
},
fr: {
dashboard: 'Tableau de bord',
profile: 'Profil',
savedCompanies: 'Entreprises sauvegardées',
notifications: 'Notifications',
signOut: 'Déconnexion',
renterPortal: 'Espace client',
rights: 'Tous droits réservés.',
},
ar: {
dashboard: 'لوحة التحكم',
profile: 'الملف الشخصي',
savedCompanies: 'الشركات المحفوظة',
notifications: 'الإشعارات',
signOut: 'تسجيل الخروج',
renterPortal: 'بوابة المستأجر',
rights: 'جميع الحقوق محفوظة.',
},
} as const
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'R'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
export default function RenterShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const { language } = useMarketplacePreferences()
const dict = dicts[language]
const isRtl = language === 'ar'
const [open, setOpen] = useState(false)
const [userInitials, setUserInitials] = useState('R')
const [userName, setUserName] = useState<string | null>(null)
const [userEmail, setUserEmail] = useState<string | null>(null)
useEffect(() => {
loadRenterProfile()
.then((profile) => {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
setUserName(fullName || profile.email.split('@')[0])
setUserEmail(profile.email)
setUserInitials(computeInitials(fullName || profile.email))
})
.catch(() => {})
}, [])
useEffect(() => {
setOpen(false)
}, [pathname])
function signOut() {
clearRenterSession()
router.push('/renter/sign-in')
}
const isActive = (item: (typeof NAV_ITEMS)[number]) => {
if ('exact' in item && item.exact) return pathname === item.href
return pathname.startsWith(item.href)
}
const activeNav = NAV_ITEMS.find((item) => isActive(item))
const pageTitle = activeNav ? (dict[activeNav.key as keyof typeof dict] as string) : dict.dashboard
return (
<div className="flex min-h-screen bg-stone-50 dark:bg-stone-950">
{open && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
{!open && (
<button
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-stone-900 text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
<aside
className={[
'fixed inset-y-0 z-40 flex w-64 flex-col bg-stone-900 transition-transform duration-300',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : isRtl ? 'translate-x-full' : '-translate-x-full',
].join(' ')}
>
<div className="flex items-center gap-3 border-b border-stone-800 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-amber-500 overflow-hidden">
<Car className="h-4 w-4 text-white" />
</div>
<span className="truncate text-sm font-bold tracking-wide text-white">
{dict.renterPortal}
</span>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active
? 'bg-amber-500 text-white'
: 'text-stone-400 hover:bg-stone-800 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict[item.key as keyof typeof dict] as string}
</Link>
)
})}
</nav>
<div className="border-t border-stone-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-500 text-xs font-semibold text-white">
{userInitials}
</div>
<div className="min-w-0 flex-1">
{userName && (
<p className="truncate text-sm font-medium text-white">{userName}</p>
)}
{userEmail && (
<p className="truncate text-xs text-stone-400">{userEmail}</p>
)}
</div>
</div>
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-stone-400 transition-colors hover:bg-stone-800 hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
</div>
<button
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-stone-900 text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
<div
className={[
'flex min-h-screen min-w-0 flex-1 flex-col',
isRtl ? 'lg:mr-64' : 'lg:ml-64',
].join(' ')}
>
<header className="flex h-16 items-center justify-between border-b border-stone-200 bg-white px-6 transition-colors dark:border-stone-800 dark:bg-stone-950">
<h1 className="text-lg font-semibold text-stone-900 dark:text-stone-100">{pageTitle}</h1>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-500 text-xs font-semibold text-white">
{userInitials}
</div>
</header>
<main className="flex-1 overflow-y-auto py-8">
{children}
</main>
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 transition-colors dark:border-stone-800 dark:bg-stone-950/90">
<p className="text-center text-xs text-stone-400 dark:text-stone-500">
&copy; {new Date().getFullYear()} RentalDriveGo. {dict.rights}
</p>
</footer>
</div>
</div>
)
}
@@ -1,22 +1,16 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { useMarketplacePreferences } from './MarketplaceShell'
type FrameId = 'dashboard' | 'admin' | 'public-site'
type FrameId = 'sign-in' | 'public-site'
const frameConfig: Record<FrameId, { label: string; port: number; path: string }> = {
dashboard: {
label: 'Company Workspace',
'sign-in': {
label: 'Sign in',
port: 3001,
path: '/sign-in',
},
admin: {
label: 'Platform Operations',
port: 3002,
path: '/',
},
'public-site': {
label: 'Branded Website',
port: 3003,
@@ -24,24 +18,41 @@ const frameConfig: Record<FrameId, { label: string; port: number; path: string }
},
}
function getDefaultFramePath(target: FrameId) {
if (typeof window === 'undefined') {
return frameConfig[target].path
}
const employeeToken = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('employee_token='))
if (target === 'sign-in' && employeeToken) {
return '/dashboard'
}
return frameConfig[target].path
}
function withPort(port: number, path: string) {
if (typeof window === 'undefined') return path
const url = new URL(window.location.href)
url.port = String(port)
url.pathname = path
url.search = ''
const target = new URL(path, url.origin)
url.pathname = target.pathname
url.search = target.search
url.hash = ''
return url.toString()
}
export default function WorkspaceFrame({ target }: { target: FrameId }) {
const pathname = usePathname()
const { language } = useMarketplacePreferences()
const { language, theme } = useMarketplacePreferences()
const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target]
const fillViewport = pathname === '/company-workspace'
const frameHeight = fillViewport ? '100vh' : 'calc(100vh - 56px)'
const frameHeight = 'calc(100vh - 56px)'
const loadingLabel = {
en: 'Loading',
fr: 'Chargement de',
@@ -49,8 +60,30 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
}[language]
useEffect(() => {
setSrc(withPort(config.port, config.path))
}, [config.path, config.port])
setFramePath(getDefaultFramePath(target))
}, [target])
useEffect(() => {
function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string; path?: string }
if ((message.type === 'rentaldrivego:embedded-path' || message.type === 'rentaldrivego:employee-login') && typeof message.path === 'string') {
setFramePath(message.path)
}
}
window.addEventListener('message', handleFrameMessage)
return () => window.removeEventListener('message', handleFrameMessage)
}, [])
useEffect(() => {
const nextUrl = new URL(withPort(config.port, framePath))
nextUrl.searchParams.set('theme', theme)
nextUrl.searchParams.set('lang', language)
setSrc(nextUrl.toString())
}, [config.port, framePath, language, theme])
return (
<main className="bg-stone-950" style={{ minHeight: frameHeight }}>
+5 -2
View File
@@ -1,7 +1,10 @@
import { cookies } from 'next/headers'
import { MARKETPLACE_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from './i18n'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from './i18n'
export function getMarketplaceLanguage(): MarketplaceLanguage {
const cookieValue = cookies().get(MARKETPLACE_LANGUAGE_COOKIE)?.value
const cookieStore = cookies()
const cookieValue =
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
cookieStore.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
return isMarketplaceLanguage(cookieValue) ? cookieValue : 'en'
}
+1
View File
@@ -1,6 +1,7 @@
export type MarketplaceLanguage = 'en' | 'fr' | 'ar'
export const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export function isMarketplaceLanguage(value: string | null | undefined): value is MarketplaceLanguage {
return value === 'en' || value === 'fr' || value === 'ar'
+103
View File
@@ -0,0 +1,103 @@
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
function readEmployeeToken() {
if (typeof window === 'undefined') return null
const localToken = window.localStorage.getItem('employee_token')
if (localToken) return localToken
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith('employee_token='))
?.split('=')[1] ?? null
}
function decodeEmployeeId(token: string | null) {
if (!token) return null
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded)) as { sub?: string }
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
} catch {
return null
}
}
export function getScopedPreferenceKey(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}:${employeeId}` : baseKey
}
export function getScopedPreferenceCookieName(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}--${employeeId}` : baseKey
}
function readCookie(name: string) {
if (typeof document === 'undefined') return null
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith(`${name}=`))
?.slice(name.length + 1) ?? null
}
function writeCookie(name: string, value: string) {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
}
export function readCurrentUserScopedPreference(baseKey: string) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
}
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
const sharedCookie = readCookie(baseKey)
if (sharedCookie) return sharedCookie
const scopedKey = getScopedPreferenceKey(baseKey)
const candidates = [scopedKey, baseKey, ...legacyKeys]
for (const key of candidates) {
const value = window.localStorage.getItem(key)
if (value) return value
}
return null
}
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return
const scopedKey = getScopedPreferenceKey(baseKey)
const scopedCookie = getScopedPreferenceCookieName(baseKey)
writeCookie(baseKey, value)
if (scopedCookie !== baseKey) {
writeCookie(scopedCookie, value)
}
window.localStorage.setItem(scopedKey, value)
window.localStorage.setItem(baseKey, value)
for (const key of legacyKeys) {
window.localStorage.setItem(key, value)
}
}