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
+21
View File
@@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(cp *)",
"Bash(npm run type-check *)",
"Bash(npm run test *)",
"Bash(npm run build *)",
"Bash(npm run lint *)",
"Bash(docker *)",
"Bash(Bash(curl *))",
"Bash(Bash(nc *))",
"Bash(Bash(curl *))",
"Bash(Bash(jq *))",
"Bash(curl *)",
"Bash(find *)",
"Bash(docker-compose *)",
"Bash(npm run docker:dev:start:tools *)",
"Bash(npm *)"
]
}
}
+13
View File
@@ -0,0 +1,13 @@
# Agent Instructions
Always communicate in English.
Use English for:
- Responses
- Code comments
- Documentation
- Commit messages
- Pull request descriptions
- Error analysis
Only use another language if explicitly requested.
+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": [
{
+63
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",
@@ -151,6 +174,25 @@
"Site public de réservation par entreprise",
"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": [
{
@@ -183,7 +225,9 @@
"pillars",
"audiences",
"features",
"howitworks",
"steps",
"testimonials",
"closing"
],
"heroKicker": "RentalDriveGo",
@@ -239,6 +283,25 @@
"موقع حجز عام مخصص لكل شركة",
"إدارة العملاء والتحليلات والفوترة"
],
"howitworksKicker": "البدء",
"howitworksTitle": "كيف يعمل",
"howitworksSteps": [
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
},
{
"number": "2",
"title": "أضف أسطولك",
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
},
{
"number": "3",
"title": "ابدأ البيع",
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
}
],
"stepsTitle": "كيف تبدأ الشركات",
"steps": [
{
+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,8 +201,18 @@ export default function HomeContent() {
</div>
</div>
) : <div />}
</div>
</section> : null}
{hasSection('steps') ? (
{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>
@@ -210,10 +233,16 @@ export default function HomeContent() {
))}
</div>
</div>
) : <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": [
{
+1
View File
@@ -36,6 +36,7 @@ services:
PGADMIN_DEFAULT_EMAIL: admin@rentaldrivego.dev
PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "False"
PGADMIN_CONFIG_SERVER_MODE: "False"
ports:
- "5050:80"
volumes:
+292
View File
@@ -0,0 +1,292 @@
# Front-End Redesign Plan
> Based on: https://isysolution.com/ reference | Preserving existing brand colors
---
## 1. Existing Color System (Keep As-Is)
### Primary Palette
| Token | Value | Usage |
|---|---|---|
| Blue 950 | `#172554` | Body text, headings |
| Blue 900 | `#1e3a8a` | Dark backgrounds, sidebar |
| Blue 800 | `#1e40af` | Borders, accents |
| Orange 600 | `#ea580c` | Primary CTA buttons |
| Orange 500 | `#f97316` | Dark mode CTA |
| White | `#ffffff` | Backgrounds, cards |
### CSS Variables (dashboard)
```css
--primary: #2563eb;
--primary-hover: #1d4ed8;
--accent: #f97316;
--sidebar-bg: #0a1128;
--sidebar-text: #94a3b8;
--sidebar-active: #ffffff;
```
### Background Gradients (keep)
- Light: `linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%)`
- Dark: `linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%)`
### Glow Effects (keep)
- Orange glow top-left: `rgba(234, 88, 12, 0.24)`
- Blue glow top-right: `rgba(59, 130, 246, 0.18)`
---
## 2. Typography (Keep As-Is)
- Font: **Inter** (already configured)
- Headings: `font-black`, `tracking-[-0.04em]`
- Kicker labels: `text-xs font-bold uppercase tracking-[0.28em] text-orange-600`
---
## 3. What to Redesign (Inspired by isysolution.com)
### 3.1 Marketplace — Public Pages
#### Home Page (`/`)
Current issues to fix:
- Hero section needs stronger visual hierarchy
- Add animated stats/counters section
- Add "How it works" step-by-step section
- Add industry/use-case cards section
- Add social proof / testimonials section
- Add partners/logos strip
Redesign plan:
```
Hero
├── Kicker badge (orange)
├── Large headline (blue-950)
├── Subheadline
├── Two CTAs: "Get Started" (orange pill) + "See Demo" (outline pill)
└── Hero visual / mockup card
Stats Strip
└── 34 animated counters (vehicles, companies, bookings, countries)
How It Works
├── Step 1: Create your workspace
├── Step 2: Add your fleet
├── Step 3: Go live on marketplace
└── Step 4: Manage everything from dashboard
Services / Features Grid
└── 6 feature cards (icon + title + description)
└── Keep existing feature copy, improve card layout
Industries / Use Cases
└── Cards: Car Rental, Truck Fleet, Luxury, Tourism
Testimonials
└── 23 quote cards with avatar, name, company
Pricing CTA Banner
└── "Start free for 90 days" with orange CTA
Footer
```
#### Features Page (`/features`)
- Convert flat card list to alternating left/right sections
- Add icon illustrations per feature
- Add a comparison table vs competitors
#### Pricing Page (`/pricing`)
- Add toggle: Monthly / Annual
- Highlight recommended plan with orange border
- Improve FAQ accordion styling
- Add trust badges below pricing cards
#### Explore Page (`/explore`)
- Improve search form visual weight
- Add filter sidebar
- Improve vehicle card design (image, price, rating, CTA)
#### Company Workspace Page
- Add hero with company branding mockup
- Add feature highlights
---
### 3.2 Dashboard — Internal Pages
#### Sidebar Navigation
Current: basic sidebar
Redesign:
- Sticky sidebar with logo at top
- Grouped nav sections with labels
- Active state: white text + orange left border indicator
- Collapsed mobile drawer
#### Dashboard Home (`/`)
- Add KPI summary cards row (reservations, revenue, fleet, customers)
- Add recent activity feed
- Add quick action buttons
- Add chart section (reservations over time)
#### Reservations (`/reservations`)
- Improve table design: rounded rows, status badges
- Add filter bar at top
- Add empty state illustration
#### Fleet (`/fleet`)
- Add vehicle card grid view option
- Improve vehicle detail page layout
- Add availability calendar
#### Customers (`/customers`)
- Add customer profile cards
- Improve table with avatar column
#### Contracts (`/contracts`)
- Add contract status timeline
- Improve contract detail page
#### Reports (`/reports`)
- Add chart components (bar, line, doughnut)
- Add date range picker
---
## 4. Component Redesign Checklist
### Shared Components
- [ ] `MarketplaceHeader` — add mobile hamburger menu, improve nav spacing
- [ ] `MarketplaceFooter` — add logo, social links, newsletter input
- [ ] `BookingForm` — improve step layout, add progress indicator
- [ ] `WorkspaceTabs` — improve active tab styling
### New Components to Build
- [ ] `StatsStrip` — animated counters
- [ ] `HowItWorks` — numbered steps with icons
- [ ] `TestimonialsSection` — quote cards
- [ ] `FeatureAlternating` — left/right alternating sections
- [ ] `PricingToggle` — monthly/annual switch
- [ ] `KPICard` — dashboard metric card
- [ ] `ActivityFeed` — recent events list
- [ ] `ChartCard` — wrapper for chart components
- [ ] `EmptyState` — illustration + message
- [ ] `FilterBar` — search + filter controls
- [ ] `VehicleCard` — improved explore card
---
## 5. Design Patterns to Adopt from Reference Site
| Pattern | Where to Apply |
|---|---|
| Sticky header with blur backdrop | Already done — keep |
| Section kicker labels (orange caps) | Already done — keep |
| Large bold hero headlines | Already done — keep |
| Rounded pill buttons | Already done — keep |
| Card with soft shadow + blur | Already done — keep |
| Alternating feature sections | Features page |
| Numbered step process | Home "How it works" |
| Stats/counters strip | Home page |
| Testimonials carousel | Home page |
| Mega footer with columns | Marketplace footer |
| Sidebar with grouped nav | Dashboard |
| KPI metric cards | Dashboard home |
---
## 6. Responsive Strategy
### Breakpoints (Tailwind defaults — keep)
- Mobile: `< 640px`
- Tablet: `sm: 640px`
- Desktop: `lg: 1024px`
- Wide: `xl: 1280px`, `2xl: 1536px`
### Mobile Improvements Needed
- [ ] Hamburger menu for marketplace header
- [ ] Collapsible sidebar for dashboard
- [ ] Stack pricing cards vertically
- [ ] Full-width booking form
- [ ] Swipeable testimonials
---
## 7. Animation & Interaction
### Recommended Additions
- Scroll-triggered fade-in for sections (use `IntersectionObserver` or Framer Motion)
- Counter animation for stats strip
- Hover lift effect on feature cards (`hover:-translate-y-1 transition`)
- Smooth accordion for FAQ
- Loading skeleton for data tables
### Keep Existing
- `transition-colors` on theme switch
- Backdrop blur on header/footer
- Button hover states
---
## 8. Implementation Phases
### Phase 1 — Marketplace Public Pages (Priority)
1. Home page redesign
- Stats strip
- How it works section
- Testimonials section
2. Features page alternating layout
3. Pricing page toggle + improved cards
4. Explore page vehicle cards
### Phase 2 — Dashboard UX
1. Sidebar redesign
2. Dashboard home KPI cards + charts
3. Reservations table improvements
4. Fleet page improvements
### Phase 3 — Polish
1. Mobile responsiveness audit
2. Animations
3. Empty states
4. Accessibility audit
5. Performance optimization
---
## 9. Files to Modify
### Marketplace
| File | Change |
|---|---|
| `src/app/(public)/HomeContent.tsx` | Add stats, how-it-works, testimonials sections |
| `src/app/(public)/features/page.tsx` | Alternating layout |
| `src/app/(public)/pricing/PricingPageContent.tsx` | Toggle + improved cards |
| `src/app/(public)/explore/ExploreVehicleGrid.tsx` | Improved vehicle cards |
| `src/components/MarketplaceHeader.tsx` | Mobile menu |
| `src/components/MarketplaceFooter.tsx` | Mega footer |
| `src/app/globals.css` | No changes needed |
### Dashboard
| File | Change |
|---|---|
| `src/app/(dashboard)/page.tsx` | KPI cards + charts |
| `src/app/(dashboard)/reservations/page.tsx` | Table improvements |
| `src/app/(dashboard)/fleet/page.tsx` | Card grid view |
| `src/app/globals.css` | No changes needed |
---
## 10. What NOT to Change
- Color palette (blue-950, orange-600, gradients)
- Font (Inter)
- Dark mode implementation
- CSS utility classes (`.card`, `.site-page`, `.site-panel`, `.btn-primary`, etc.)
- Tailwind config
- Authentication flows
- API integration layer
---
## Notes
- The existing design system is already high quality — the goal is to add missing sections and improve layout density, not rebuild from scratch.
- All new sections should use existing CSS utility classes (`.site-section`, `.site-panel`, `.site-kicker`, `.site-title`, `.site-lead`).
- Maintain dark mode support on all new components.
+15 -135
View File
@@ -1563,6 +1563,21 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.8.tgz",
"integrity": "sha512-MWIALcC/R12KNHTAliyX96XwwnHLGNKRJHsaFiGKE0fF9sEoXHUlxvdrrl3iQfIKWfBcNdQ9rrMYHc1qFGc71A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
@@ -10623,141 +10638,6 @@
"devDependencies": {
"typescript": "^5.4.0"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.8.tgz",
"integrity": "sha512-R/MDVX4Cj+bzvTNkDWNyA09yCPBZB0J1m8P3VAmWAzx/J7DctA1wxya1LU3CcPv2szER3AImHEtsgScauH8SsQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.8.tgz",
"integrity": "sha512-XH2u8C/meLJmEZbN0G5MRZp8tvxBz5KQ+xv4vX34zL8gL3//5qkc5M/aLsvNAfg5/KbcjIVe3h6aiO0wJcNWKA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.8.tgz",
"integrity": "sha512-zyA8bCtlAXOzZD6ri6hIi5Z7hW5+07ea81KK0xdezmJHrbrAgvwIRd6VbkddJTapmZVSmWzbpAdYoi1HZNF7KA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.8.tgz",
"integrity": "sha512-MWIALcC/R12KNHTAliyX96XwwnHLGNKRJHsaFiGKE0fF9sEoXHUlxvdrrl3iQfIKWfBcNdQ9rrMYHc1qFGc71A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.8.tgz",
"integrity": "sha512-SD/R9QU6+wUB3lH6InhOLu1eP3wle4sSjsNdMwk24yFwaWUB5TAtscvFbOqh1AYBsJvHRez3tYHETfbBFd4dRg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.8.tgz",
"integrity": "sha512-F1Al3cRKhYR7/V1RaXnHceqilNLFDe47w7Li/Asph1SklaHHm/b2FahLar2nWct9l9K143NlqbAUFQMjx30QYA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.8.tgz",
"integrity": "sha512-LxhCLToY0id1CkmYFi9sTM2RfoBWszr6mmMrBtmTFqKF0fXX3ZIbM8m1sKoNFIrZFn7gFroT7cnZea5neufSHg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.8.tgz",
"integrity": "sha512-xfGxs1zIkTOLzH8i1WWCeTqQhNqyrv3aVT55PMy7Ul3dfxlA3In7hNEJ3Uo2qpalEBOlkG6G6FLRMWbUrxL+qQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
"integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+3 -3
View File
@@ -62,11 +62,11 @@
"security:scan": "npm run security:static && npm audit --production"
},
"devDependencies": {
"turbo": "^1.13.0",
"typescript": "^5.4.0",
"@types/node": "^20.12.0",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
"eslint": "^8.57.0"
"turbo": "^1.13.0",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=20.0.0",
+155
View File
@@ -16,13 +16,28 @@ export type MarketplaceHomepageStep = {
body: string
}
export type MarketplaceHomepageTestimonial = {
quote: string
author: string
role: string
company?: string
}
export type MarketplaceHomepageHowItWorksStep = {
number: string
title: string
description: string
}
export type MarketplaceHomepageSectionType =
| 'hero'
| 'surface'
| 'pillars'
| 'audiences'
| 'features'
| 'howitworks'
| 'steps'
| 'testimonials'
| 'closing'
export type MarketplaceHomepageContent = {
@@ -49,9 +64,15 @@ export type MarketplaceHomepageContent = {
metrics: MarketplaceHomepageMetric[]
featureLabel: string
features: string[]
howitworksKicker: string
howitworksTitle: string
howitworksSteps: MarketplaceHomepageHowItWorksStep[]
stepsTitle: string
steps: MarketplaceHomepageStep[]
stepLabel: string
testimonialsKicker: string
testimonialsTitle: string
testimonials: MarketplaceHomepageTestimonial[]
readyKicker: string
readyTitle: string
readyBody: string
@@ -67,7 +88,9 @@ export const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[
'pillars',
'audiences',
'features',
'howitworks',
'steps',
'testimonials',
'closing',
]
@@ -113,6 +136,25 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
'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: [
['1', 'Create your company workspace', 'Pick a plan, launch your 90-day trial, and verify the owner account.'],
@@ -120,6 +162,31 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Step',
testimonialsKicker: 'CUSTOMER STORIES',
testimonialsTitle: 'Trusted by rental companies worldwide',
testimonials: [
{
quote:
'RentalDriveGo transformed how we manage our fleet and reach customers. The marketplace visibility has doubled our bookings.',
author: 'Sarah Johnson',
role: 'Fleet Manager',
company: 'Premium Auto Rentals',
},
{
quote:
'The two-sided platform gives us control over our operations while renters get a seamless experience. Perfect balance.',
author: 'Ahmed Hassan',
role: 'Operations Director',
company: 'Desert Wheels',
},
{
quote:
'We appreciated how easy it was to set up our branded booking flow. Our customers love the direct payment experience.',
author: 'Marie Dubois',
role: 'Owner',
company: 'Provence Car Hire',
},
],
readyKicker: 'Ready to launch',
readyTitle: 'The marketplace homepage should explain the product in seconds.',
readyBody:
@@ -168,6 +235,25 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
'Site public de réservation par entreprise',
'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: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire.'],
@@ -175,6 +261,31 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Étape',
testimonialsKicker: 'HISTOIRES DE CLIENTS',
testimonialsTitle: 'Approuvé par des entreprises de location du monde entier',
testimonials: [
{
quote:
'RentalDriveGo a transformé la gestion de notre flotte et notre portée client. La visibilité marketplace a doublé nos réservations.',
author: 'Sophie Martin',
role: 'Responsable de Flotte',
company: 'Location Premium',
},
{
quote:
'La plateforme bilatérale nous donne le contrôle de nos opérations tandis que les clients bénéficient d\'une expérience transparente. L\'équilibre parfait.',
author: 'Jean-Claude Barbier',
role: 'Directeur des Opérations',
company: 'Sahara Cars',
},
{
quote:
'Nous avons apprécié la facilité de mise en place de notre parcours de réservation personnalisé. Nos clients adorent l\'expérience de paiement direct.',
author: 'Isabelle Leclerc',
role: 'Propriétaire',
company: 'Provence Auto',
},
],
readyKicker: 'Prêt à démarrer',
readyTitle: 'La page daccueil marketplace doit présenter le produit en quelques secondes.',
readyBody:
@@ -223,6 +334,25 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
'موقع حجز عام مخصص لكل شركة',
'إدارة العملاء والتحليلات والفوترة',
],
howitworksKicker: 'البدء',
howitworksTitle: 'كيف يعمل',
howitworksSteps: [
{
number: '1',
title: 'إنشاء حساب',
description: 'قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً.',
},
{
number: '2',
title: 'أضف أسطولك',
description: 'قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق.',
},
{
number: '3',
title: 'ابدأ البيع',
description: 'يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة.',
},
],
stepsTitle: 'كيف تبدأ الشركات',
steps: [
['1', 'أنشئ مساحة عمل شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك.'],
@@ -230,6 +360,31 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'الخطوة',
testimonialsKicker: 'قصص العملاء',
testimonialsTitle: 'موثوقة من قبل شركات التأجير في جميع أنحاء العالم',
testimonials: [
{
quote:
'غيّرت RentalDriveGo طريقة إدارة أسطولنا والوصول إلى العملاء. الظهور في السوق قد ضاعف حجوزاتنا.',
author: 'فاطمة الزهراء',
role: 'مدير الأسطول',
company: 'تأجير السيارات الممتازة',
},
{
quote:
'تمنحنا المنصة ثنائية الجانب التحكم في عملياتنا بينما يحصل المستأجرون على تجربة سلسة. التوازن المثالي.',
author: 'محمد علي',
role: 'مدير العمليات',
company: 'صحراء ويلز',
},
{
quote:
'أقدرنا على سهولة إعداد مسار الحجز المخصص بنا. يحب عملاؤنا تجربة الدفع المباشر.',
author: 'ليلى حسن',
role: 'المالك',
company: 'تأجير السيارات بالبحر الأبيض',
},
],
readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyBody: