redesign the dashboard
Build & Deploy / Build & Push Docker Image (push) Failing after 43s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 4m59s
Test / Marketplace Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled

This commit is contained in:
root
2026-06-24 02:00:31 -04:00
parent 6c50458aca
commit 8ee9bc0bca
32 changed files with 1314 additions and 260 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -15,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
+69 -6
View File
@@ -7,7 +7,9 @@
"pillars",
"audiences",
"features",
"howitworks",
"steps",
"testimonials",
"closing"
],
"heroKicker": "RentalDriveGo",
@@ -63,6 +65,25 @@
"Branded public booking site per company",
"Customer CRM, analytics, and billing controls"
],
"howitworksKicker": "GETTING STARTED",
"howitworksTitle": "How It Works",
"howitworksSteps": [
{
"number": "1",
"title": "Create Account",
"description": "Sign up for your company workspace and choose your pricing plan for the 90-day free trial."
},
{
"number": "2",
"title": "Add Your Fleet",
"description": "Upload vehicle photos, set prices, and create offers visible on the marketplace."
},
{
"number": "3",
"title": "Start Selling",
"description": "Renters discover your fleet, and bookings flow directly to your branded checkout."
}
],
"stepsTitle": "How companies launch",
"steps": [
{
@@ -95,7 +116,9 @@
"pillars",
"audiences",
"features",
"howitworks",
"steps",
"testimonials",
"closing"
],
"heroKicker": "RentalDriveGo",
@@ -149,9 +172,28 @@
"Gestion de flotte avec téléversement de plusieurs photos",
"Offres marketplace et redirection vers la réservation",
"Site public de réservation par entreprise",
"CRM client, analytics et contrôle de facturation"
],
"stepsTitle": "Comment les entreprises démarrent",
"CRM client, analytics et contrôle de facturation"
],
"howitworksKicker": "MISE EN ROUTE",
"howitworksTitle": "Comment ça marche",
"howitworksSteps": [
{
"number": "1",
"title": "Créer un compte",
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 90 jours."
},
{
"number": "2",
"title": "Ajouter votre flotte",
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace."
},
{
"number": "3",
"title": "Commencer à vendre",
"description": "Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque."
}
],
"stepsTitle": "Comment les entreprises démarrent",
"steps": [
{
"step": "1",
@@ -183,7 +225,9 @@
"pillars",
"audiences",
"features",
"howitworks",
"steps",
"testimonials",
"closing"
],
"heroKicker": "RentalDriveGo",
@@ -237,9 +281,28 @@
"إدارة الأسطول مع رفع عدة صور",
"عروض السوق والتحويل إلى مسار الحجز",
"موقع حجز عام مخصص لكل شركة",
"إدارة العملاء والتحليلات والفوترة"
],
"stepsTitle": "كيف تبدأ الشركات",
"إدارة العملاء والتحليلات والفوترة"
],
"howitworksKicker": "البدء",
"howitworksTitle": "كيف يعمل",
"howitworksSteps": [
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
},
{
"number": "2",
"title": "أضف أسطولك",
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
},
{
"number": "3",
"title": "ابدأ البيع",
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
}
],
"stepsTitle": "كيف تبدأ الشركات",
"steps": [
{
"step": "1",
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+72 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle, Plus, BarChart3, FileText, TrendingUp } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
@@ -40,6 +40,9 @@ interface DashboardData {
interface EmployeeProfile {
role?: string
firstName?: string
lastName?: string
email?: string
}
const STATUS_COLORS: Record<string, string> = {
@@ -50,6 +53,30 @@ const STATUS_COLORS: Record<string, string> = {
CANCELLED: 'badge-red',
}
interface QuickAction {
icon: React.ReactNode
label: string
href: string
color: string
}
function QuickActionsSection({ actions }: { actions: QuickAction[] }) {
return (
<div className="flex flex-wrap gap-3">
{actions.map((action) => (
<Link
key={action.href}
href={action.href}
className={`flex items-center gap-2 px-4 py-3 rounded-lg border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition-all ${action.color}`}
>
{action.icon}
<span className="text-sm font-medium">{action.label}</span>
</Link>
))}
</div>
)
}
function SubscriptionBanner({
status,
trialEndsAt,
@@ -108,12 +135,14 @@ function SubscriptionBanner({
export default function DashboardPage() {
const { dict, language } = useDashboardI18n()
const d = dict.dashboardPage
const nav = dict.nav
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<{ code: string; message: string } | null>(null)
const [employeeRole, setEmployeeRole] = useState<string | null>(null)
const [employeeName, setEmployeeName] = useState<string | null>(null)
useEffect(() => {
try {
@@ -121,6 +150,8 @@ export default function DashboardPage() {
if (!cached) return
const profile = JSON.parse(cached) as EmployeeProfile
setEmployeeRole(profile.role ?? null)
const fullName = [profile.firstName, profile.lastName].filter(Boolean).join(' ') || null
setEmployeeName(fullName)
} catch {}
}, [])
@@ -158,6 +189,46 @@ export default function DashboardPage() {
return (
<div className="space-y-6">
{/* Welcome Section */}
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-1">
Welcome back{employeeName ? `, ${employeeName}` : ''}!
</h1>
<p className="text-sm text-slate-600 mb-4">
Here's what's happening with your fleet today
</p>
</div>
{/* Quick Actions */}
<QuickActionsSection
actions={[
{
icon: <Plus className="w-4 h-4" />,
label: 'New Reservation',
href: '/reservations/new',
color: 'text-blue-600',
},
{
icon: <Car className="w-4 h-4" />,
label: nav.fleet,
href: '/fleet',
color: 'text-green-600',
},
{
icon: <Users className="w-4 h-4" />,
label: nav.customers,
href: '/customers',
color: 'text-purple-600',
},
{
icon: <FileText className="w-4 h-4" />,
label: nav.reports,
href: '/reports',
color: 'text-orange-600',
},
]}
/>
{data?.subscription && (
<SubscriptionBanner
status={data.subscription.status}
+1 -1
View File
@@ -15,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6 -1
View File
@@ -9,7 +9,12 @@ const securityHeaders = buildSecurityHeaders({
})
const nextConfig = {
images: {
domains: ['res.cloudinary.com'],
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
],
},
transpilePackages: ['@rentaldrivego/types'],
async headers() {
@@ -2,6 +2,9 @@
import { useEffect, useState } from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { StatsStrip } from '@/components/StatsStrip'
import { HowItWorks } from '@/components/HowItWorks'
import { TestimonialsSection } from '@/components/TestimonialsSection'
import {
cloneMarketplaceHomepageContent,
resolveMarketplaceHomepageSections,
@@ -171,6 +174,16 @@ export default function HomeContent() {
</div>
</section> : null}
{hasSection('pillars') ? (
<StatsStrip
stats={dict.metrics.map((m) => ({
label: m.label,
value: parseInt(m.value) || 0,
}))}
kicker="BY THE NUMBERS"
/>
) : null}
{(hasSection('features') || hasSection('steps')) ? <section className="shell py-10">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
{hasSection('features') ? (
@@ -188,32 +201,48 @@ export default function HomeContent() {
</div>
</div>
) : <div />}
{hasSection('steps') ? (
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-blue-900 dark:bg-blue-950">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.readyKicker}</p>
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">{dict.stepsTitle}</h2>
</div>
</div>
<div className="mt-8 space-y-4">
{dict.steps.map(({ step, title, body }) => (
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
{dict.stepLabel} {step}
</p>
<h3 className="mt-3 text-lg font-black text-blue-950 dark:text-stone-50">{title}</h3>
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
</article>
))}
</div>
</div>
) : <div />}
</div>
</section> : null}
{hasSection('howitworks') ? (
<HowItWorks
steps={dict.howitworksSteps}
kicker={dict.howitworksKicker}
title={dict.howitworksTitle}
/>
) : null}
{hasSection('steps') ? <section className="shell py-10">
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-blue-900 dark:bg-blue-950">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.readyKicker}</p>
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">{dict.stepsTitle}</h2>
</div>
</div>
<div className="mt-8 space-y-4">
{dict.steps.map(({ step, title, body }) => (
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
{dict.stepLabel} {step}
</p>
<h3 className="mt-3 text-lg font-black text-blue-950 dark:text-stone-50">{title}</h3>
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
</article>
))}
</div>
</div>
</section> : null}
{hasSection('testimonials') && dict.testimonials && dict.testimonials.length > 0 ? (
<TestimonialsSection
testimonials={dict.testimonials}
kicker={dict.testimonialsKicker}
title={dict.testimonialsTitle}
/>
) : null}
{hasSection('closing') ? <section className="shell pb-16 pt-6 sm:pb-20">
<div className="overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] px-8 py-10 text-white dark:border-blue-800">
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
@@ -66,7 +66,8 @@ const CATEGORY_VALUES = [
'TRUCK',
] as const
export default async function ExplorePage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
export default async function ExplorePage({ searchParams }: { searchParams?: Promise<Record<string, string | string[] | undefined>> }) {
const resolvedSearchParams = await searchParams
const language = await getMarketplaceLanguage()
const dict = {
en: {
@@ -209,22 +210,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
},
}[language]
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
const pickupLocation = typeof searchParams?.pickupLocation === 'string' ? searchParams.pickupLocation : city
const dropoffLocation = typeof searchParams?.dropoffLocation === 'string' ? searchParams.dropoffLocation : ''
const dropoffMode = searchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof searchParams?.pickupDate === 'string' ? searchParams.pickupDate : ''
const pickupTime = typeof searchParams?.pickupTime === 'string' ? searchParams.pickupTime : '10:00'
const returnDate = typeof searchParams?.returnDate === 'string' ? searchParams.returnDate : ''
const returnTime = typeof searchParams?.returnTime === 'string' ? searchParams.returnTime : '10:00'
const promoCode = typeof searchParams?.promoCode === 'string' ? searchParams.promoCode : ''
const driverAge = typeof searchParams?.driverAge === 'string' ? searchParams.driverAge : '23+'
const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
const make = typeof searchParams?.make === 'string' ? searchParams.make : ''
const model = typeof searchParams?.model === 'string' ? searchParams.model : ''
const startDate = typeof searchParams?.startDate === 'string' ? searchParams.startDate : ''
const endDate = typeof searchParams?.endDate === 'string' ? searchParams.endDate : ''
const city = typeof resolvedSearchParams?.city === 'string' ? resolvedSearchParams.city : ''
const pickupLocation = typeof resolvedSearchParams?.pickupLocation === 'string' ? resolvedSearchParams.pickupLocation : city
const dropoffLocation = typeof resolvedSearchParams?.dropoffLocation === 'string' ? resolvedSearchParams.dropoffLocation : ''
const dropoffMode = resolvedSearchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof resolvedSearchParams?.pickupDate === 'string' ? resolvedSearchParams.pickupDate : ''
const pickupTime = typeof resolvedSearchParams?.pickupTime === 'string' ? resolvedSearchParams.pickupTime : '10:00'
const returnDate = typeof resolvedSearchParams?.returnDate === 'string' ? resolvedSearchParams.returnDate : ''
const returnTime = typeof resolvedSearchParams?.returnTime === 'string' ? resolvedSearchParams.returnTime : '10:00'
const promoCode = typeof resolvedSearchParams?.promoCode === 'string' ? resolvedSearchParams.promoCode : ''
const driverAge = typeof resolvedSearchParams?.driverAge === 'string' ? resolvedSearchParams.driverAge : '23+'
const category = typeof resolvedSearchParams?.category === 'string' ? resolvedSearchParams.category : ''
const transmission = typeof resolvedSearchParams?.transmission === 'string' ? resolvedSearchParams.transmission : ''
const make = typeof resolvedSearchParams?.make === 'string' ? resolvedSearchParams.make : ''
const model = typeof resolvedSearchParams?.model === 'string' ? resolvedSearchParams.model : ''
const startDate = typeof resolvedSearchParams?.startDate === 'string' ? resolvedSearchParams.startDate : ''
const endDate = typeof resolvedSearchParams?.endDate === 'string' ? resolvedSearchParams.endDate : ''
const query = new URLSearchParams()
if (pickupLocation) query.set('city', pickupLocation)
@@ -1,70 +1,158 @@
'use client'
import {
BarChart3,
Zap,
Users,
CreditCard,
Lock,
Bell,
} from 'lucide-react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { FeatureAlternating } from '@/components/FeatureAlternating'
const copy = {
en: {
kicker: 'Features',
title: 'Operations, marketplace discovery, and branded booking.',
body: "RentalDriveGo combines company-only operations with a public marketplace that sends renters to each company's own payment flow.",
cards: [
['Private dashboard', 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.'],
['Marketplace discovery', 'Published vehicles and public offers appear on `/explore` for cross-company browsing.'],
['Branded booking site', 'Each company gets its own subdomain where renters complete booking and payment directly.'],
['Payment flexibility', 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.'],
['Advanced rental ops', 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.'],
['Notification controls', 'Both company staff and renters can manage event-by-channel notification preferences.'],
features: [
{
title: 'Private dashboard',
body: 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.',
icon: 'BarChart3',
},
{
title: 'Marketplace discovery',
body: 'Published vehicles and public offers appear on `/explore` for cross-company browsing.',
icon: 'Zap',
},
{
title: 'Branded booking site',
body: 'Each company gets its own subdomain where renters complete booking and payment directly.',
icon: 'Lock',
},
{
title: 'Payment flexibility',
body: 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.',
icon: 'CreditCard',
},
{
title: 'Advanced rental ops',
body: 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.',
icon: 'Users',
},
{
title: 'Notification controls',
body: 'Both company staff and renters can manage event-by-channel notification preferences.',
icon: 'Bell',
},
],
},
fr: {
kicker: 'Fonctionnalités',
title: 'Opérations, découverte marketplace et réservation de marque.',
body: "RentalDriveGo combine des opérations privées pour l'entreprise avec une marketplace publique qui redirige les clients vers le parcours de paiement propre à chaque société.",
cards: [
['Tableau de bord privé', "Flotte, réservations, CRM, permissions d'équipe, facturation et rapports restent isolés par entreprise."],
['Découverte marketplace', 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.'],
['Site de réservation de marque', 'Chaque entreprise dispose de son propre sous-domaine où les clients finalisent la réservation et le paiement.'],
['Flexibilité de paiement', 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.'],
['Opérations avancées', 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées sont gérés dans les paramètres du tableau de bord.'],
['Contrôle des notifications', 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.'],
features: [
{
title: 'Tableau de bord privé',
body: "Flotte, réservations, CRM, permissions d'équipe, facturation et rapports restent isolés par entreprise.",
icon: 'BarChart3',
},
{
title: 'Découverte marketplace',
body: 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.',
icon: 'Zap',
},
{
title: 'Site de réservation de marque',
body: 'Chaque entreprise dispose de son propre sous-domaine où les clients finalisent la réservation et le paiement.',
icon: 'Lock',
},
{
title: 'Flexibilité de paiement',
body: 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.',
icon: 'CreditCard',
},
{
title: 'Opérations avancées',
body: 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées sont gérés dans les paramètres du tableau de bord.',
icon: 'Users',
},
{
title: 'Contrôle des notifications',
body: 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.',
icon: 'Bell',
},
],
},
ar: {
kicker: 'المزايا',
title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
cards: [
['لوحة تحكم خاصة', 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.'],
['اكتشاف عبر السوق', 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.'],
['موقع حجز مخصص', 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.'],
['مرونة الدفع', 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.'],
['عمليات متقدمة', 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.'],
['التحكم في الإشعارات', 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.'],
features: [
{
title: 'لوحة تحكم خاصة',
body: 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.',
icon: 'BarChart3',
},
{
title: 'اكتشاف عبر السوق',
body: 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.',
icon: 'Zap',
},
{
title: 'موقع حجز مخصص',
body: 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.',
icon: 'Lock',
},
{
title: 'مرونة الدفع',
body: 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.',
icon: 'CreditCard',
},
{
title: 'عمليات متقدمة',
body: 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.',
icon: 'Users',
},
{
title: 'التحكم في الإشعارات',
body: 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.',
icon: 'Bell',
},
],
},
} as const
const iconMap = {
BarChart3,
Zap,
Users,
CreditCard,
Lock,
Bell,
}
export default function FeaturesPage() {
const { language } = useMarketplacePreferences()
const dict = copy[language]
const features = dict.features.map((f) => ({
...f,
icon: iconMap[f.icon as keyof typeof iconMap],
}))
return (
<main className="site-page">
<div className="site-section space-y-12">
<div className="site-section space-y-16">
<div className="max-w-3xl">
<p className="site-kicker">{dict.kicker}</p>
<h1 className="site-title">{dict.title}</h1>
<p className="site-lead">{dict.body}</p>
</div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{dict.cards.map(([title, body]) => (
<article key={title} className="card p-8">
<h2 className="text-xl font-black text-stone-900 dark:text-stone-100">{title}</h2>
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-400">{body}</p>
</article>
))}
</div>
<FeatureAlternating features={features} />
</div>
</main>
)
@@ -203,7 +203,7 @@ export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { in
))}
</ul>
<Link
<a
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}&currency=MAD`}
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
plan.highlight
@@ -212,7 +212,7 @@ export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { in
}`}
>
{dict.getStarted}
</Link>
</a>
</div>
</div>
)
@@ -2,12 +2,38 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function RenterSignInPage() {
type SearchParams = Record<string, string | string[] | undefined>
function buildSearch(params: SearchParams): string {
const nextParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
nextParams.set(key, value)
continue
}
if (Array.isArray(value)) {
for (const entry of value) nextParams.append(key, entry)
}
}
const search = nextParams.toString()
return search ? `?${search}` : ''
}
export default async function RenterSignInPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/sign-in`)
const params = await searchParams
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
}
@@ -2,12 +2,38 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function RenterSignUpPage() {
type SearchParams = Record<string, string | string[] | undefined>
function buildSearch(params: SearchParams): string {
const nextParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
nextParams.set(key, value)
continue
}
if (Array.isArray(value)) {
for (const entry of value) nextParams.append(key, entry)
}
}
const search = nextParams.toString()
return search ? `?${search}` : ''
}
export default async function RenterSignUpPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/sign-in`)
const params = await searchParams
redirect(`${dashboardUrl}/sign-up${buildSearch(params)}`)
}
+3 -2
View File
@@ -5,6 +5,7 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
type SearchParams = Record<string, string | string[] | undefined>
function buildSearch(params: SearchParams): string {
const nextParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
@@ -25,7 +26,7 @@ function buildSearch(params: SearchParams): string {
export default async function SignInPage({
searchParams,
}: {
searchParams: SearchParams
searchParams: Promise<SearchParams>
}) {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
@@ -33,7 +34,7 @@ export default async function SignInPage({
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
const params = searchParams
const params = await searchParams
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`)
}
@@ -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,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>
)
}
@@ -131,14 +131,14 @@ export default function MarketplaceHeader({
>
{dict.explore}
</Link>
<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}
</Link>
</a>
{companyName ? (
<Link
<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"
>
@@ -146,14 +146,14 @@ export default function MarketplaceHeader({
{companyInitial(companyName)}
</span>
{companyName}
</Link>
</a>
) : (
<Link
<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}
</Link>
</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">
@@ -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>
)
}
@@ -198,11 +198,11 @@ export default function WorkspaceTabs() {
<Link href="/explore" className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white">
{copy.exploreVehicles}
</Link>
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
<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}
</Link>
</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">
+14 -14
View File
@@ -39,12 +39,12 @@ beforeEach(() => {
nextServer.next.mockClear()
})
describe('marketplace middleware language bootstrap', () => {
describe('marketplace proxy language bootstrap', () => {
it('rejects middleware subrequest headers before language handling', async () => {
const { middleware } = await import('./middleware')
it('rejects proxy subrequest headers before language handling', async () => {
const { proxy } = await import('./proxy')
const response = middleware(request({
const response = proxy(request({
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
}) as never) as any
@@ -52,9 +52,9 @@ describe('marketplace middleware language bootstrap', () => {
expect(nextServer.next).not.toHaveBeenCalled()
})
it('does nothing when the canonical shared language cookie is valid', async () => {
const { middleware } = await import('./middleware')
const { proxy } = await import('./proxy')
const response = middleware(request({ cookies: { 'rentaldrivego-language': 'fr' } }) as never) as any
const response = proxy(request({ cookies: { 'rentaldrivego-language': 'fr' } }) as never) as any
expect(response.kind).toBe('next')
expect(nextServer.next).toHaveBeenCalledWith()
@@ -62,9 +62,9 @@ describe('marketplace middleware language bootstrap', () => {
})
it('migrates the legacy marketplace language cookie into the shared cookie and request headers', async () => {
const { middleware } = await import('./middleware')
const { proxy } = await import('./proxy')
const response = middleware(request({
const response = proxy(request({
cookies: { 'marketplace-language': 'ar' },
headers: { cookie: 'session=abc' },
}) as never) as any
@@ -77,23 +77,23 @@ describe('marketplace middleware language bootstrap', () => {
})
it('detects Arabic and French from Accept-Language before falling back to English', async () => {
const { middleware } = await import('./middleware')
const { proxy } = await import('./proxy')
const arabic = middleware(request({ headers: { 'accept-language': 'ar-MA,fr;q=0.8,en;q=0.6' } }) as never) as any
const arabic = proxy(request({ headers: { 'accept-language': 'ar-MA,fr;q=0.8,en;q=0.6' } }) as never) as any
expect(arabic.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'ar', expect.any(Object))
expect(arabic.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=ar')
const french = middleware(request({ headers: { 'accept-language': 'de-DE,fr-FR;q=0.7,en;q=0.3' } }) as never) as any
const french = proxy(request({ headers: { 'accept-language': 'de-DE,fr-FR;q=0.7,en;q=0.3' } }) as never) as any
expect(french.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'fr', expect.any(Object))
const fallback = middleware(request({ headers: { 'accept-language': 'de-DE,es;q=0.9' } }) as never) as any
const fallback = proxy(request({ headers: { 'accept-language': 'de-DE,es;q=0.9' } }) as never) as any
expect(fallback.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'en', expect.any(Object))
})
it('rejects invalid shared and legacy language values before resolving from headers', async () => {
const { middleware } = await import('./middleware')
const { proxy } = await import('./proxy')
const response = middleware(request({
const response = proxy(request({
cookies: { 'rentaldrivego-language': 'es', 'marketplace-language': 'it' },
headers: { 'accept-language': 'fr-CA,ar;q=0.5' },
}) as never) as any
@@ -4,7 +4,6 @@ import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
if (!request.headers.has('x-middleware-subrequest')) return null
return new NextResponse('Unsupported internal request header', { status: 400 })
@@ -23,13 +22,12 @@ function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
return 'en'
}
export function middleware(request: NextRequest) {
export function proxy(request: NextRequest) {
const rejected = rejectInternalSubrequest(request)
if (rejected) return rejected
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
// Already has the canonical language cookie — no action needed
if (isValidLanguage(sharedLang)) return NextResponse.next()
const legacyLang = request.cookies.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
@@ -37,9 +35,6 @@ export function middleware(request: NextRequest) {
? legacyLang
: detectFromAcceptLanguage(request.headers.get('accept-language'))
// Inject the resolved language into the request cookie header so that
// getMarketplaceLanguage() (which calls cookies()) sees the correct value
// even on this very first render — before the browser has stored the cookie.
const requestHeaders = new Headers(request.headers)
const existing = request.headers.get('cookie') ?? ''
const injected = `${SHARED_LANGUAGE_COOKIE}=${resolved}`
@@ -47,7 +42,6 @@ export function middleware(request: NextRequest) {
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Persist in the browser for future requests
response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, {
maxAge: 365 * 24 * 60 * 60,
path: '/',
@@ -59,6 +53,6 @@ export function middleware(request: NextRequest) {
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:png|ico|jpg|jpeg|svg|webp)$).*)',
'/((?!_next/static|_next/image|favicon\.ico|.*\.(?:png|ico|jpg|jpeg|svg|webp)$).*)',
],
}
+1 -1
View File
@@ -15,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{