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
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:
@@ -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 *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Vendored
+2
-1
@@ -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.
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -151,6 +174,25 @@
|
|||||||
"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": [
|
||||||
{
|
{
|
||||||
@@ -183,7 +225,9 @@
|
|||||||
"pillars",
|
"pillars",
|
||||||
"audiences",
|
"audiences",
|
||||||
"features",
|
"features",
|
||||||
|
"howitworks",
|
||||||
"steps",
|
"steps",
|
||||||
|
"testimonials",
|
||||||
"closing"
|
"closing"
|
||||||
],
|
],
|
||||||
"heroKicker": "RentalDriveGo",
|
"heroKicker": "RentalDriveGo",
|
||||||
@@ -239,6 +283,25 @@
|
|||||||
"موقع حجز عام مخصص لكل شركة",
|
"موقع حجز عام مخصص لكل شركة",
|
||||||
"إدارة العملاء والتحليلات والفوترة"
|
"إدارة العملاء والتحليلات والفوترة"
|
||||||
],
|
],
|
||||||
|
"howitworksKicker": "البدء",
|
||||||
|
"howitworksTitle": "كيف يعمل",
|
||||||
|
"howitworksSteps": [
|
||||||
|
{
|
||||||
|
"number": "1",
|
||||||
|
"title": "إنشاء حساب",
|
||||||
|
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "2",
|
||||||
|
"title": "أضف أسطولك",
|
||||||
|
"description": "قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "3",
|
||||||
|
"title": "ابدأ البيع",
|
||||||
|
"description": "يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة."
|
||||||
|
}
|
||||||
|
],
|
||||||
"stepsTitle": "كيف تبدأ الشركات",
|
"stepsTitle": "كيف تبدأ الشركات",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+2
-1
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+2
-1
@@ -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.
|
||||||
|
|||||||
@@ -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,8 +201,18 @@ export default function HomeContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="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 className="flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -210,10 +233,16 @@ export default function HomeContent() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : <div />}
|
|
||||||
</div>
|
|
||||||
</section> : null}
|
</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()}¤cy=MAD`}
|
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}¤cy=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)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)$).*)',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
└── 3–4 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
|
||||||
|
└── 2–3 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.
|
||||||
Generated
+15
-135
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 d’accueil marketplace doit présenter le produit en quelques secondes.',
|
readyTitle: 'La page d’accueil 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:
|
||||||
|
|||||||
Reference in New Issue
Block a user