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" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // 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", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
+69 -6
View File
@@ -7,7 +7,9 @@
"pillars", "pillars",
"audiences", "audiences",
"features", "features",
"howitworks",
"steps", "steps",
"testimonials",
"closing" "closing"
], ],
"heroKicker": "RentalDriveGo", "heroKicker": "RentalDriveGo",
@@ -63,6 +65,25 @@
"Branded public booking site per company", "Branded public booking site per company",
"Customer CRM, analytics, and billing controls" "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", "stepsTitle": "How companies launch",
"steps": [ "steps": [
{ {
@@ -95,7 +116,9 @@
"pillars", "pillars",
"audiences", "audiences",
"features", "features",
"howitworks",
"steps", "steps",
"testimonials",
"closing" "closing"
], ],
"heroKicker": "RentalDriveGo", "heroKicker": "RentalDriveGo",
@@ -149,9 +172,28 @@
"Gestion de flotte avec téléversement de plusieurs photos", "Gestion de flotte avec téléversement de plusieurs photos",
"Offres marketplace et redirection vers la réservation", "Offres marketplace et redirection vers la réservation",
"Site public de réservation par entreprise", "Site public de réservation par entreprise",
"CRM client, analytics et contrôle de facturation" "CRM client, analytics et contrôle de facturation"
], ],
"stepsTitle": "Comment les entreprises démarrent", "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": [ "steps": [
{ {
"step": "1", "step": "1",
@@ -183,7 +225,9 @@
"pillars", "pillars",
"audiences", "audiences",
"features", "features",
"howitworks",
"steps", "steps",
"testimonials",
"closing" "closing"
], ],
"heroKicker": "RentalDriveGo", "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": [ "steps": [
{ {
"step": "1", "step": "1",
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // 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 Link from 'next/link'
import { useEffect, useState } from 'react' 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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard' import StatCard from '@/components/ui/StatCard'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api' import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
@@ -40,6 +40,9 @@ interface DashboardData {
interface EmployeeProfile { interface EmployeeProfile {
role?: string role?: string
firstName?: string
lastName?: string
email?: string
} }
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
@@ -50,6 +53,30 @@ const STATUS_COLORS: Record<string, string> = {
CANCELLED: 'badge-red', 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({ function SubscriptionBanner({
status, status,
trialEndsAt, trialEndsAt,
@@ -108,12 +135,14 @@ function SubscriptionBanner({
export default function DashboardPage() { export default function DashboardPage() {
const { dict, language } = useDashboardI18n() const { dict, language } = useDashboardI18n()
const d = dict.dashboardPage const d = dict.dashboardPage
const nav = dict.nav
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US' const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<{ code: string; message: string } | null>(null) const [error, setError] = useState<{ code: string; message: string } | null>(null)
const [employeeRole, setEmployeeRole] = useState<string | null>(null) const [employeeRole, setEmployeeRole] = useState<string | null>(null)
const [employeeName, setEmployeeName] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
try { try {
@@ -121,6 +150,8 @@ export default function DashboardPage() {
if (!cached) return if (!cached) return
const profile = JSON.parse(cached) as EmployeeProfile const profile = JSON.parse(cached) as EmployeeProfile
setEmployeeRole(profile.role ?? null) setEmployeeRole(profile.role ?? null)
const fullName = [profile.firstName, profile.lastName].filter(Boolean).join(' ') || null
setEmployeeName(fullName)
} catch {} } catch {}
}, []) }, [])
@@ -158,6 +189,46 @@ export default function DashboardPage() {
return ( return (
<div className="space-y-6"> <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 && ( {data?.subscription && (
<SubscriptionBanner <SubscriptionBanner
status={data.subscription.status} status={data.subscription.status}
+1 -1
View File
@@ -15,7 +15,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // 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 = { const nextConfig = {
images: { images: {
domains: ['res.cloudinary.com'], remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
],
}, },
transpilePackages: ['@rentaldrivego/types'], transpilePackages: ['@rentaldrivego/types'],
async headers() { async headers() {
@@ -2,6 +2,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { StatsStrip } from '@/components/StatsStrip'
import { HowItWorks } from '@/components/HowItWorks'
import { TestimonialsSection } from '@/components/TestimonialsSection'
import { import {
cloneMarketplaceHomepageContent, cloneMarketplaceHomepageContent,
resolveMarketplaceHomepageSections, resolveMarketplaceHomepageSections,
@@ -171,6 +174,16 @@ export default function HomeContent() {
</div> </div>
</section> : null} </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"> {(hasSection('features') || hasSection('steps')) ? <section className="shell py-10">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]"> <div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
{hasSection('features') ? ( {hasSection('features') ? (
@@ -188,32 +201,48 @@ export default function HomeContent() {
</div> </div>
</div> </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> </div>
</section> : null} </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"> {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="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"> <div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
@@ -66,7 +66,8 @@ const CATEGORY_VALUES = [
'TRUCK', 'TRUCK',
] as const ] 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 language = await getMarketplaceLanguage()
const dict = { const dict = {
en: { en: {
@@ -209,22 +210,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
}, },
}[language] }[language]
const city = typeof searchParams?.city === 'string' ? searchParams.city : '' const city = typeof resolvedSearchParams?.city === 'string' ? resolvedSearchParams.city : ''
const pickupLocation = typeof searchParams?.pickupLocation === 'string' ? searchParams.pickupLocation : city const pickupLocation = typeof resolvedSearchParams?.pickupLocation === 'string' ? resolvedSearchParams.pickupLocation : city
const dropoffLocation = typeof searchParams?.dropoffLocation === 'string' ? searchParams.dropoffLocation : '' const dropoffLocation = typeof resolvedSearchParams?.dropoffLocation === 'string' ? resolvedSearchParams.dropoffLocation : ''
const dropoffMode = searchParams?.dropoffMode === 'different' ? 'different' : 'same' const dropoffMode = resolvedSearchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof searchParams?.pickupDate === 'string' ? searchParams.pickupDate : '' const pickupDate = typeof resolvedSearchParams?.pickupDate === 'string' ? resolvedSearchParams.pickupDate : ''
const pickupTime = typeof searchParams?.pickupTime === 'string' ? searchParams.pickupTime : '10:00' const pickupTime = typeof resolvedSearchParams?.pickupTime === 'string' ? resolvedSearchParams.pickupTime : '10:00'
const returnDate = typeof searchParams?.returnDate === 'string' ? searchParams.returnDate : '' const returnDate = typeof resolvedSearchParams?.returnDate === 'string' ? resolvedSearchParams.returnDate : ''
const returnTime = typeof searchParams?.returnTime === 'string' ? searchParams.returnTime : '10:00' const returnTime = typeof resolvedSearchParams?.returnTime === 'string' ? resolvedSearchParams.returnTime : '10:00'
const promoCode = typeof searchParams?.promoCode === 'string' ? searchParams.promoCode : '' const promoCode = typeof resolvedSearchParams?.promoCode === 'string' ? resolvedSearchParams.promoCode : ''
const driverAge = typeof searchParams?.driverAge === 'string' ? searchParams.driverAge : '23+' const driverAge = typeof resolvedSearchParams?.driverAge === 'string' ? resolvedSearchParams.driverAge : '23+'
const category = typeof searchParams?.category === 'string' ? searchParams.category : '' const category = typeof resolvedSearchParams?.category === 'string' ? resolvedSearchParams.category : ''
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : '' const transmission = typeof resolvedSearchParams?.transmission === 'string' ? resolvedSearchParams.transmission : ''
const make = typeof searchParams?.make === 'string' ? searchParams.make : '' const make = typeof resolvedSearchParams?.make === 'string' ? resolvedSearchParams.make : ''
const model = typeof searchParams?.model === 'string' ? searchParams.model : '' const model = typeof resolvedSearchParams?.model === 'string' ? resolvedSearchParams.model : ''
const startDate = typeof searchParams?.startDate === 'string' ? searchParams.startDate : '' const startDate = typeof resolvedSearchParams?.startDate === 'string' ? resolvedSearchParams.startDate : ''
const endDate = typeof searchParams?.endDate === 'string' ? searchParams.endDate : '' const endDate = typeof resolvedSearchParams?.endDate === 'string' ? resolvedSearchParams.endDate : ''
const query = new URLSearchParams() const query = new URLSearchParams()
if (pickupLocation) query.set('city', pickupLocation) if (pickupLocation) query.set('city', pickupLocation)
@@ -1,70 +1,158 @@
'use client' 'use client'
import {
BarChart3,
Zap,
Users,
CreditCard,
Lock,
Bell,
} from 'lucide-react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell' import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { FeatureAlternating } from '@/components/FeatureAlternating'
const copy = { const copy = {
en: { en: {
kicker: 'Features', kicker: 'Features',
title: 'Operations, marketplace discovery, and branded booking.', 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.", body: "RentalDriveGo combines company-only operations with a public marketplace that sends renters to each company's own payment flow.",
cards: [ features: [
['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.'], title: 'Private dashboard',
['Branded booking site', 'Each company gets its own subdomain where renters complete booking and payment directly.'], body: 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.',
['Payment flexibility', 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.'], icon: 'BarChart3',
['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.'], {
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: { fr: {
kicker: 'Fonctionnalités', kicker: 'Fonctionnalités',
title: 'Opérations, découverte marketplace et réservation de marque.', 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é.", 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: [ features: [
['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.'], title: 'Tableau de bord privé',
['Site de réservation de marque', 'Chaque entreprise dispose de son propre sous-domaine où les clients finalisent la réservation et le paiement.'], body: "Flotte, réservations, CRM, permissions d'équipe, facturation et rapports restent isolés par entreprise.",
['Flexibilité de paiement', 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.'], icon: 'BarChart3',
['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.'], {
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: { ar: {
kicker: 'المزايا', kicker: 'المزايا',
title: 'العمليات، اكتشاف السوق، والحجز المخصص.', title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.', body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
cards: [ features: [
['لوحة تحكم خاصة', 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.'], {
['اكتشاف عبر السوق', 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.'], title: 'لوحة تحكم خاصة',
['موقع حجز مخصص', 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.'], body: 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.',
['مرونة الدفع', 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.'], 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 } as const
const iconMap = {
BarChart3,
Zap,
Users,
CreditCard,
Lock,
Bell,
}
export default function FeaturesPage() { export default function FeaturesPage() {
const { language } = useMarketplacePreferences() const { language } = useMarketplacePreferences()
const dict = copy[language] const dict = copy[language]
const features = dict.features.map((f) => ({
...f,
icon: iconMap[f.icon as keyof typeof iconMap],
}))
return ( return (
<main className="site-page"> <main className="site-page">
<div className="site-section space-y-12"> <div className="site-section space-y-16">
<div className="max-w-3xl"> <div className="max-w-3xl">
<p className="site-kicker">{dict.kicker}</p> <p className="site-kicker">{dict.kicker}</p>
<h1 className="site-title">{dict.title}</h1> <h1 className="site-title">{dict.title}</h1>
<p className="site-lead">{dict.body}</p> <p className="site-lead">{dict.body}</p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3"> <FeatureAlternating features={features} />
{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>
</div> </div>
</main> </main>
) )
@@ -203,7 +203,7 @@ export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { in
))} ))}
</ul> </ul>
<Link <a
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}&currency=MAD`} 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 ${ className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
plan.highlight plan.highlight
@@ -212,7 +212,7 @@ export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { in
}`} }`}
> >
{dict.getStarted} {dict.getStarted}
</Link> </a>
</div> </div>
</div> </div>
) )
@@ -2,12 +2,38 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls' 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 requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl( const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard', process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
requestHeaders.get('host'), requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'), 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 { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls' 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 requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl( const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard', process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
requestHeaders.get('host'), requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'), 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> type SearchParams = Record<string, string | string[] | undefined>
function buildSearch(params: SearchParams): string { function buildSearch(params: SearchParams): string {
const nextParams = new URLSearchParams() const nextParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
@@ -25,7 +26,7 @@ function buildSearch(params: SearchParams): string {
export default async function SignInPage({ export default async function SignInPage({
searchParams, searchParams,
}: { }: {
searchParams: SearchParams searchParams: Promise<SearchParams>
}) { }) {
const requestHeaders = await headers() const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl( const dashboardUrl = resolveServerAppUrl(
@@ -33,7 +34,7 @@ export default async function SignInPage({
requestHeaders.get('host'), requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'), requestHeaders.get('x-forwarded-proto'),
) )
const params = searchParams const params = await searchParams
redirect(`${dashboardUrl}/sign-in${buildSearch(params)}`) 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} {dict.explore}
</Link> </Link>
<Link <a
href={signInHref} 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" 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} {dict.signIn}
</Link> </a>
{companyName ? ( {companyName ? (
<Link <a
href={dashboardUrl} 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" 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)} {companyInitial(companyName)}
</span> </span>
{companyName} {companyName}
</Link> </a>
) : ( ) : (
<Link <a
href={ownerWorkspaceHref(dashboardUrl, companyName)} 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" 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} {dict.ownerSignIn}
</Link> </a>
)} )}
</nav> </nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1"> <div 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"> <Link href="/explore" className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white">
{copy.exploreVehicles} {copy.exploreVehicles}
</Link> </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' theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href={`${dashboardUrl}/sign-in`}> }`} href={`${dashboardUrl}/sign-in`}>
{copy.ownerSignIn} {copy.ownerSignIn}
</Link> </a>
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${ <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' theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href="/pricing"> }`} href="/pricing">
+14 -14
View File
@@ -39,12 +39,12 @@ beforeEach(() => {
nextServer.next.mockClear() nextServer.next.mockClear()
}) })
describe('marketplace middleware language bootstrap', () => { describe('marketplace proxy language bootstrap', () => {
it('rejects middleware subrequest headers before language handling', async () => { it('rejects proxy subrequest headers before language handling', async () => {
const { middleware } = await import('./middleware') const { proxy } = await import('./proxy')
const response = middleware(request({ const response = proxy(request({
headers: { 'x-middleware-subrequest': 'middleware:middleware' }, headers: { 'x-middleware-subrequest': 'middleware:middleware' },
}) as never) as any }) as never) as any
@@ -52,9 +52,9 @@ describe('marketplace middleware language bootstrap', () => {
expect(nextServer.next).not.toHaveBeenCalled() expect(nextServer.next).not.toHaveBeenCalled()
}) })
it('does nothing when the canonical shared language cookie is valid', async () => { 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(response.kind).toBe('next')
expect(nextServer.next).toHaveBeenCalledWith() 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 () => { 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' }, cookies: { 'marketplace-language': 'ar' },
headers: { cookie: 'session=abc' }, headers: { cookie: 'session=abc' },
}) as never) as any }) 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 () => { 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.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'ar', expect.any(Object))
expect(arabic.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=ar') 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)) 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)) expect(fallback.cookies.set).toHaveBeenCalledWith('rentaldrivego-language', 'en', expect.any(Object))
}) })
it('rejects invalid shared and legacy language values before resolving from headers', async () => { 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' }, cookies: { 'rentaldrivego-language': 'es', 'marketplace-language': 'it' },
headers: { 'accept-language': 'fr-CA,ar;q=0.5' }, headers: { 'accept-language': 'fr-CA,ar;q=0.5' },
}) as never) as any }) as never) as any
@@ -4,7 +4,6 @@ import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language' const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language' const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
function rejectInternalSubrequest(request: NextRequest): NextResponse | null { function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
if (!request.headers.has('x-middleware-subrequest')) return null if (!request.headers.has('x-middleware-subrequest')) return null
return new NextResponse('Unsupported internal request header', { status: 400 }) return new NextResponse('Unsupported internal request header', { status: 400 })
@@ -23,13 +22,12 @@ function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
return 'en' return 'en'
} }
export function middleware(request: NextRequest) { export function proxy(request: NextRequest) {
const rejected = rejectInternalSubrequest(request) const rejected = rejectInternalSubrequest(request)
if (rejected) return rejected if (rejected) return rejected
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
// Already has the canonical language cookie — no action needed
if (isValidLanguage(sharedLang)) return NextResponse.next() if (isValidLanguage(sharedLang)) return NextResponse.next()
const legacyLang = request.cookies.get(MARKETPLACE_LANGUAGE_COOKIE)?.value const legacyLang = request.cookies.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
@@ -37,9 +35,6 @@ export function middleware(request: NextRequest) {
? legacyLang ? legacyLang
: detectFromAcceptLanguage(request.headers.get('accept-language')) : 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 requestHeaders = new Headers(request.headers)
const existing = request.headers.get('cookie') ?? '' const existing = request.headers.get('cookie') ?? ''
const injected = `${SHARED_LANGUAGE_COOKIE}=${resolved}` const injected = `${SHARED_LANGUAGE_COOKIE}=${resolved}`
@@ -47,7 +42,6 @@ export function middleware(request: NextRequest) {
const response = NextResponse.next({ request: { headers: requestHeaders } }) const response = NextResponse.next({ request: { headers: requestHeaders } })
// Persist in the browser for future requests
response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, { response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, {
maxAge: 365 * 24 * 60 * 60, maxAge: 365 * 24 * 60 * 60,
path: '/', path: '/',
@@ -59,6 +53,6 @@ export function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ 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", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
+1
View File
@@ -36,6 +36,7 @@ services:
PGADMIN_DEFAULT_EMAIL: admin@rentaldrivego.dev PGADMIN_DEFAULT_EMAIL: admin@rentaldrivego.dev
PGADMIN_DEFAULT_PASSWORD: admin PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "False" PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: "False"
PGADMIN_CONFIG_SERVER_MODE: "False"
ports: ports:
- "5050:80" - "5050:80"
volumes: 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" "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": { "node_modules/@noble/ciphers": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
@@ -10623,141 +10638,6 @@
"devDependencies": { "devDependencies": {
"typescript": "^5.4.0" "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" "security:scan": "npm run security:static && npm audit --production"
}, },
"devDependencies": { "devDependencies": {
"turbo": "^1.13.0",
"typescript": "^5.4.0",
"@types/node": "^20.12.0", "@types/node": "^20.12.0",
"eslint": "^8.57.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"eslint": "^8.57.0" "turbo": "^1.13.0",
"typescript": "^5.4.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0", "node": ">=20.0.0",
+155
View File
@@ -16,13 +16,28 @@ export type MarketplaceHomepageStep = {
body: string 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 = export type MarketplaceHomepageSectionType =
| 'hero' | 'hero'
| 'surface' | 'surface'
| 'pillars' | 'pillars'
| 'audiences' | 'audiences'
| 'features' | 'features'
| 'howitworks'
| 'steps' | 'steps'
| 'testimonials'
| 'closing' | 'closing'
export type MarketplaceHomepageContent = { export type MarketplaceHomepageContent = {
@@ -49,9 +64,15 @@ export type MarketplaceHomepageContent = {
metrics: MarketplaceHomepageMetric[] metrics: MarketplaceHomepageMetric[]
featureLabel: string featureLabel: string
features: string[] features: string[]
howitworksKicker: string
howitworksTitle: string
howitworksSteps: MarketplaceHomepageHowItWorksStep[]
stepsTitle: string stepsTitle: string
steps: MarketplaceHomepageStep[] steps: MarketplaceHomepageStep[]
stepLabel: string stepLabel: string
testimonialsKicker: string
testimonialsTitle: string
testimonials: MarketplaceHomepageTestimonial[]
readyKicker: string readyKicker: string
readyTitle: string readyTitle: string
readyBody: string readyBody: string
@@ -67,7 +88,9 @@ export const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[
'pillars', 'pillars',
'audiences', 'audiences',
'features', 'features',
'howitworks',
'steps', 'steps',
'testimonials',
'closing', 'closing',
] ]
@@ -113,6 +136,25 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
'Branded public booking site per company', 'Branded public booking site per company',
'Customer CRM, analytics, and billing controls', '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', stepsTitle: 'How companies launch',
steps: [ steps: [
['1', 'Create your company workspace', 'Pick a plan, launch your 90-day trial, and verify the owner account.'], ['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.'], ['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! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Step', 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', readyKicker: 'Ready to launch',
readyTitle: 'The marketplace homepage should explain the product in seconds.', readyTitle: 'The marketplace homepage should explain the product in seconds.',
readyBody: readyBody:
@@ -168,6 +235,25 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
'Site public de réservation par entreprise', 'Site public de réservation par entreprise',
'CRM client, analytics et contrôle de facturation', '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', stepsTitle: 'Comment les entreprises démarrent',
steps: [ steps: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire.'], ['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.'], ['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! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Étape', 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', readyKicker: 'Prêt à démarrer',
readyTitle: 'La page daccueil marketplace doit présenter le produit en quelques secondes.', readyTitle: 'La page daccueil marketplace doit présenter le produit en quelques secondes.',
readyBody: 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: 'كيف تبدأ الشركات', stepsTitle: 'كيف تبدأ الشركات',
steps: [ steps: [
['1', 'أنشئ مساحة عمل شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك.'], ['1', 'أنشئ مساحة عمل شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك.'],
@@ -230,6 +360,31 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'], ['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'الخطوة', stepLabel: 'الخطوة',
testimonialsKicker: 'قصص العملاء',
testimonialsTitle: 'موثوقة من قبل شركات التأجير في جميع أنحاء العالم',
testimonials: [
{
quote:
'غيّرت RentalDriveGo طريقة إدارة أسطولنا والوصول إلى العملاء. الظهور في السوق قد ضاعف حجوزاتنا.',
author: 'فاطمة الزهراء',
role: 'مدير الأسطول',
company: 'تأجير السيارات الممتازة',
},
{
quote:
'تمنحنا المنصة ثنائية الجانب التحكم في عملياتنا بينما يحصل المستأجرون على تجربة سلسة. التوازن المثالي.',
author: 'محمد علي',
role: 'مدير العمليات',
company: 'صحراء ويلز',
},
{
quote:
'أقدرنا على سهولة إعداد مسار الحجز المخصص بنا. يحب عملاؤنا تجربة الدفع المباشر.',
author: 'ليلى حسن',
role: 'المالك',
company: 'تأجير السيارات بالبحر الأبيض',
},
],
readyKicker: 'جاهز للانطلاق', readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.', readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyBody: readyBody: