redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -1,274 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { StatsStrip } from '@/components/StatsStrip'
|
||||
import { HowItWorks } from '@/components/HowItWorks'
|
||||
import { TestimonialsSection } from '@/components/TestimonialsSection'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function HomeContent() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const dict = content[language]
|
||||
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadHomepageContent() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/site/platform/homepage`, { cache: 'no-store' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok || !json?.data || cancelled) return
|
||||
setContent(json.data as MarketplaceHomepageConfig)
|
||||
} catch {
|
||||
// Fall back to the shared defaults when the API is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
loadHomepageContent()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
function hasSection(section: MarketplaceHomepageSectionType) {
|
||||
return sections.includes(section)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-transparent">
|
||||
<section className="relative">
|
||||
<div className="site-glow absolute inset-x-0 top-0 h-[38rem]" />
|
||||
<div className="shell relative py-10 sm:py-14 lg:py-20">
|
||||
<div className={`grid gap-8 ${hasSection('hero') && hasSection('surface') ? 'lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]' : ''}`}>
|
||||
{hasSection('hero') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-orange-700 dark:text-orange-300">{dict.heroKicker}</p>
|
||||
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-blue-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
|
||||
{dict.heroTitle}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
|
||||
{dict.heroBody}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.startTrial}
|
||||
</a>
|
||||
<a
|
||||
href="/features"
|
||||
className="rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:border-stone-200 dark:hover:text-white"
|
||||
>
|
||||
{dict.exploreVehicles}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:grid-cols-3">
|
||||
{dict.metrics.map(({ value, label }) => (
|
||||
<div key={value} className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 p-4 dark:border-blue-900 dark:bg-blue-950/50">
|
||||
<p className="text-xs font-bold tracking-[0.28em] text-stone-400 dark:text-stone-500">{value}</p>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-stone-900 dark:text-stone-100">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasSection('surface') ? (
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] p-6 text-white shadow-[0_30px_80px_rgba(6,19,46,0.40)] dark:border-blue-800">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(249,115,22,0.30),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(96,165,250,0.22),transparent_32%)]" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{dict.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{dict.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-6 text-3xl font-black leading-tight tracking-[-0.03em]">{dict.surfaceTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{dict.surfaceBody}</p>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{[
|
||||
['01', dict.trustedFleets],
|
||||
['02', dict.brandedFlows],
|
||||
['03', dict.multiTenant],
|
||||
].map(([step, label]) => (
|
||||
<div key={step} className="flex items-center justify-between rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-xs font-bold text-blue-950">
|
||||
{step}
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-white">{label}</p>
|
||||
</div>
|
||||
<span className="text-lg text-orange-300">+</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasSection('audiences') ? <div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 backdrop-blur dark:border-blue-900 dark:bg-blue-950/60">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.companyKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-stone-600 dark:text-stone-300">{dict.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-[#f7efe2] p-7 dark:border-blue-900 dark:bg-[#1d1712]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.renterKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.renterTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-700 dark:text-stone-300">{dict.renterBody}</p>
|
||||
</article>
|
||||
</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{hasSection('pillars') ? <section className="shell pb-10">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{dict.pillars.map(({ title, body }, index) => (
|
||||
<article
|
||||
key={title}
|
||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||
index === 1
|
||||
? 'border-orange-700 bg-orange-600 text-white dark:border-orange-500 dark:bg-orange-500 dark:text-white'
|
||||
: 'border-stone-200 bg-white dark:border-blue-900 dark:bg-blue-950'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-bold uppercase tracking-[0.26em] ${
|
||||
index === 1 ? 'text-stone-300 dark:text-stone-700' : 'text-stone-400 dark:text-stone-500'
|
||||
}`}
|
||||
>
|
||||
0{index + 1}
|
||||
</p>
|
||||
<h3 className="mt-4 text-2xl font-black tracking-[-0.03em]">{title}</h3>
|
||||
<p
|
||||
className={`mt-4 text-sm leading-7 ${
|
||||
index === 1 ? 'text-stone-200 dark:text-stone-800' : 'text-stone-600 dark:text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('pillars') ? (
|
||||
<StatsStrip
|
||||
stats={dict.metrics.map((m) => ({
|
||||
label: m.label,
|
||||
value: parseInt(m.value) || 0,
|
||||
}))}
|
||||
kicker="BY THE NUMBERS"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(hasSection('features') || hasSection('steps')) ? <section className="shell py-10">
|
||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
{hasSection('features') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#f5f8ff_0%,#edf2ff_100%)] p-8 dark:border-blue-900 dark:bg-[linear-gradient(160deg,#0c1830_0%,#10203e_100%)]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.featureLabel}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
{dict.features.map((item, index) => (
|
||||
<div key={item} className="flex items-start gap-4 rounded-[1.25rem] border border-stone-200/80 bg-white/85 px-4 py-4 dark:border-blue-800 dark:bg-blue-950/40">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white dark:bg-orange-500 dark:text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800 dark:text-stone-100">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('howitworks') ? (
|
||||
<HowItWorks
|
||||
steps={dict.howitworksSteps}
|
||||
kicker={dict.howitworksKicker}
|
||||
title={dict.howitworksTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasSection('steps') ? <section className="shell py-10">
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-blue-900 dark:bg-blue-950">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">{dict.stepsTitle}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{dict.steps.map(({ step, title, body }) => (
|
||||
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-blue-900 dark:bg-blue-950">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
|
||||
{dict.stepLabel} {step}
|
||||
</p>
|
||||
<h3 className="mt-3 text-lg font-black text-blue-950 dark:text-stone-50">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('testimonials') && dict.testimonials && dict.testimonials.length > 0 ? (
|
||||
<TestimonialsSection
|
||||
testimonials={dict.testimonials}
|
||||
kicker={dict.testimonialsKicker}
|
||||
title={dict.testimonialsTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasSection('closing') ? <section className="shell pb-16 pt-6 sm:pb-20">
|
||||
<div className="overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] px-8 py-10 text-white dark:border-blue-800">
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight tracking-[-0.04em] sm:text-5xl">{dict.readyTitle}</h2>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-8 text-stone-300 sm:text-base">{dict.readyBody}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<a
|
||||
href="/pricing"
|
||||
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-blue-900"
|
||||
>
|
||||
{dict.viewPricing}
|
||||
</a>
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-orange-300 px-6 py-3 text-sm font-semibold text-blue-950 transition hover:bg-orange-200"
|
||||
>
|
||||
{dict.createWorkspace}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import AppPrivacyEnPage from './app-privacy-en/page'
|
||||
import AppPrivacyFrPage from './app-privacy-fr/page'
|
||||
import AppPrivacyArPage from './app-privacy-ar/page'
|
||||
import AppTermsEnPage from './app-tc-en/page'
|
||||
import AppTermsFrPage from './app-tc-fr/page'
|
||||
import AppTermsArPage from './app-tc-ar/page'
|
||||
|
||||
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
const page = element as React.ReactElement
|
||||
expect(page.type).toBe(FooterContentPage)
|
||||
expect(page.props.slug).toBe(slug)
|
||||
expect(page.props.forcedLanguage).toBe(forcedLanguage)
|
||||
}
|
||||
|
||||
describe('marketplace static app policy pages', () => {
|
||||
it('binds app privacy pages to explicit locales', () => {
|
||||
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
|
||||
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
|
||||
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
|
||||
})
|
||||
|
||||
it('binds app terms pages to explicit locales and the app terms content slug', () => {
|
||||
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
|
||||
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
|
||||
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyArPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyEnPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyFrPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsArPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsEnPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsFrPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CompanyWorkspacePage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Users,
|
||||
CreditCard,
|
||||
Lock,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { FeatureAlternating } from '@/components/FeatureAlternating'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Features',
|
||||
title: 'Operations, marketplace discovery, and branded booking.',
|
||||
body: "RentalDriveGo combines company-only operations with a public marketplace that sends renters to each company's own payment flow.",
|
||||
features: [
|
||||
{
|
||||
title: 'Private dashboard',
|
||||
body: 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'Marketplace discovery',
|
||||
body: 'Published vehicles and public offers appear on `/explore` for cross-company browsing.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'Branded booking site',
|
||||
body: 'Each company gets its own subdomain where renters complete booking and payment directly.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'Payment flexibility',
|
||||
body: 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'Advanced rental ops',
|
||||
body: 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Notification controls',
|
||||
body: 'Both company staff and renters can manage event-by-channel notification preferences.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Fonctionnalités',
|
||||
title: 'Opérations, découverte marketplace et réservation de marque.',
|
||||
body: "RentalDriveGo combine des opérations privées pour l'entreprise avec une marketplace publique qui redirige les clients vers le parcours de paiement propre à chaque société.",
|
||||
features: [
|
||||
{
|
||||
title: 'Tableau de bord privé',
|
||||
body: "Flotte, réservations, CRM, permissions d'équipe, facturation et rapports restent isolés par entreprise.",
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'Découverte marketplace',
|
||||
body: 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'Site de réservation de marque',
|
||||
body: 'Chaque entreprise dispose de son propre sous-domaine où les clients finalisent la réservation et le paiement.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'Flexibilité de paiement',
|
||||
body: 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'Opérations avancées',
|
||||
body: 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées sont gérés dans les paramètres du tableau de bord.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Contrôle des notifications',
|
||||
body: 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'المزايا',
|
||||
title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
|
||||
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
|
||||
features: [
|
||||
{
|
||||
title: 'لوحة تحكم خاصة',
|
||||
body: 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'اكتشاف عبر السوق',
|
||||
body: 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'موقع حجز مخصص',
|
||||
body: 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'مرونة الدفع',
|
||||
body: 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'عمليات متقدمة',
|
||||
body: 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'التحكم في الإشعارات',
|
||||
body: 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
const iconMap = {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Users,
|
||||
CreditCard,
|
||||
Lock,
|
||||
Bell,
|
||||
}
|
||||
|
||||
export default function FeaturesPage() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
const features = dict.features.map((f) => ({
|
||||
...f,
|
||||
icon: iconMap[f.icon as keyof typeof iconMap],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-16">
|
||||
<div className="max-w-3xl">
|
||||
<p className="site-kicker">{dict.kicker}</p>
|
||||
<h1 className="site-title">{dict.title}</h1>
|
||||
<p className="site-lead">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<FeatureAlternating features={features} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import FooterPage, { generateStaticParams } from './page'
|
||||
import { footerPageSlugs } from '@/lib/footerContent'
|
||||
|
||||
describe('marketplace footer route', () => {
|
||||
it('generates one static route per registered footer slug', () => {
|
||||
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
|
||||
})
|
||||
|
||||
it('passes valid slugs through to FooterContentPage', () => {
|
||||
const element = FooterPage({ params: { slug: 'privacy-policy' } })
|
||||
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
expect(element.type).toBe(FooterContentPage)
|
||||
expect(element.props.slug).toBe('privacy-policy')
|
||||
expect(element.props.forcedLanguage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
|
||||
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
|
||||
expect(navigation.notFound).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return footerPageSlugs.map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
export default function FooterPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
const { slug } = params
|
||||
|
||||
if (!isFooterPageSlug(slug)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <FooterContentPage slug={slug} />
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import MarketplaceHeader from '@/components/MarketplaceHeader'
|
||||
import MarketplaceFooter from '@/components/MarketplaceFooter'
|
||||
import { useMarketplacePreferences, getFooterContent, localeOptions } from '@/components/MarketplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const { language, theme, dict, companyName, setLanguage, setTheme } = useMarketplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const footerContent = getFooterContent(language)
|
||||
const available = localeOptions.filter((o) => o.value !== language)
|
||||
const current = localeOptions.find((o) => o.value === language) ?? localeOptions[0]
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col">
|
||||
<MarketplaceHeader
|
||||
dict={dict}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
companyName={companyName}
|
||||
dashboardUrl={dashboardUrl}
|
||||
currentLanguage={{ value: current.value, flag: current.flag, shortLabel: current.value.toUpperCase() }}
|
||||
localeOptions={available.map((o) => ({ value: o.value, flag: o.flag, shortLabel: o.value.toUpperCase() }))}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<MarketplaceFooter
|
||||
primaryItems={footerContent.primary}
|
||||
secondaryItems={footerContent.secondary}
|
||||
localeLabel={footerContent.localeLabel}
|
||||
rightsLabel={footerContent.rightsLabel}
|
||||
localeOptions={available}
|
||||
currentLocale={current}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import HomeContent from './HomeContent'
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomeContent />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function PlatformOperationsPage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getCurrencyLabel, PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type Billing = 'monthly' | 'annual'
|
||||
|
||||
type PricingMatrix = Record<string, Record<string, Record<string, number>>>
|
||||
type PlanFeatureMap = Record<string, string[]>
|
||||
|
||||
type PlatformPricing = {
|
||||
prices: PricingMatrix
|
||||
planFeatures: PlanFeatureMap
|
||||
}
|
||||
|
||||
export const DEFAULT_PRICING: PlatformPricing = {
|
||||
prices: PLAN_PRICES,
|
||||
planFeatures: PLAN_FEATURES,
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
key: 'STARTER',
|
||||
name: 'Starter',
|
||||
tagline: 'Launch your fleet online',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: 'GROWTH',
|
||||
name: 'Growth',
|
||||
tagline: 'Scale with confidence',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: 'PRO',
|
||||
name: 'Pro',
|
||||
tagline: 'Enterprise-grade power',
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
monthly: 'Monthly',
|
||||
annual: 'Annual',
|
||||
yearly: '/ year',
|
||||
youSave: 'You save',
|
||||
withAnnual: 'with annual billing — billed as one payment per year.',
|
||||
mostPopular: 'Most popular',
|
||||
perMonth: '/mo',
|
||||
billedAs: 'Billed as',
|
||||
getStarted: 'Get started',
|
||||
footer: 'All plans include a 90-day free trial. No credit card required.',
|
||||
},
|
||||
fr: {
|
||||
monthly: 'Mensuel',
|
||||
annual: 'Annuel',
|
||||
yearly: '/ an',
|
||||
youSave: 'Vous économisez',
|
||||
withAnnual: 'avec la facturation annuelle, prélevée en un seul paiement par an.',
|
||||
mostPopular: 'Le plus populaire',
|
||||
perMonth: '/mois',
|
||||
billedAs: 'Facturé à',
|
||||
getStarted: 'Commencer',
|
||||
footer: "Toutes les formules incluent un essai gratuit de 90 jours. Aucune carte bancaire n'est requise.",
|
||||
},
|
||||
ar: {
|
||||
monthly: 'شهري',
|
||||
annual: 'سنوي',
|
||||
yearly: '/ سنة',
|
||||
youSave: 'توفر',
|
||||
withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
|
||||
mostPopular: 'الأكثر شيوعاً',
|
||||
perMonth: '/شهر',
|
||||
billedAs: 'يتم الفوترة بمبلغ',
|
||||
getStarted: 'ابدأ الآن',
|
||||
footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
||||
},
|
||||
} as const
|
||||
|
||||
function getPlanPrice(prices: PricingMatrix, plan: string, billingPeriod: 'MONTHLY' | 'ANNUAL') {
|
||||
return (prices[plan]?.[billingPeriod]?.MAD ?? PLAN_PRICES[plan]?.[billingPeriod]?.MAD ?? 0) / 100
|
||||
}
|
||||
|
||||
function annualSavingsPct(prices: PricingMatrix, plan: string): number {
|
||||
const monthly = getPlanPrice(prices, plan, 'MONTHLY')
|
||||
const annual = getPlanPrice(prices, plan, 'ANNUAL')
|
||||
const wouldPay = monthly * 12
|
||||
if (wouldPay <= 0) return 0
|
||||
return Math.max(0, Math.round(((wouldPay - annual) / wouldPay) * 100))
|
||||
}
|
||||
|
||||
export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { initialPricing?: PlatformPricing }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const currencyLabel = getCurrencyLabel(language)
|
||||
const dict = copy[language]
|
||||
const [billing, setBilling] = useState<Billing>('monthly')
|
||||
const [pricing, setPricing] = useState<PlatformPricing>(initialPricing)
|
||||
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
|
||||
useEffect(() => {
|
||||
marketplaceFetchOrDefault<PlatformPricing>('/site/platform/pricing', DEFAULT_PRICING)
|
||||
.then(setPricing)
|
||||
}, [])
|
||||
|
||||
const savings = annualSavingsPct(pricing.prices, 'STARTER')
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Billing toggle */}
|
||||
<div className="flex items-center justify-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-blue-950">
|
||||
{(['monthly', 'annual'] as Billing[]).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBilling(b)}
|
||||
className={`relative rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
|
||||
billing === b
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100'
|
||||
}`}
|
||||
>
|
||||
{b === 'monthly' ? dict.monthly : dict.annual}
|
||||
{b === 'annual' && billing !== 'annual' && (
|
||||
<span className="ml-2 rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-bold text-orange-700 dark:bg-orange-950/50 dark:text-orange-400">
|
||||
-{savings}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{billing === 'annual' && (
|
||||
<p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400">
|
||||
{dict.youSave} {savings}% {dict.withAnnual}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const monthlyPrice = getPlanPrice(pricing.prices, plan.key, 'MONTHLY')
|
||||
const annualPrice = getPlanPrice(pricing.prices, plan.key, 'ANNUAL')
|
||||
const displayPrice = billing === 'annual'
|
||||
? Math.round(annualPrice / 12)
|
||||
: monthlyPrice
|
||||
const features = pricing.planFeatures[plan.key] ?? PLAN_FEATURES[plan.key] ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`card relative flex flex-col overflow-hidden ${
|
||||
plan.highlight
|
||||
? 'border-orange-400 ring-2 ring-orange-400 ring-offset-2 dark:ring-offset-blue-950'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="bg-orange-500 dark:bg-orange-400 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white dark:text-stone-900">
|
||||
{dict.mostPopular}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-400">
|
||||
{plan.name}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{plan.tagline}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-4xl font-black text-stone-900 dark:text-stone-100">
|
||||
{displayPrice}
|
||||
</span>
|
||||
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">{currencyLabel}</span>
|
||||
<span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span>
|
||||
</div>
|
||||
{billing === 'annual' && (
|
||||
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
|
||||
{dict.billedAs} {annualPrice} {currencyLabel} {dict.yearly}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 flex-1 space-y-3">
|
||||
{features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700 dark:text-stone-300">
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-orange-500 dark:text-orange-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
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 ${
|
||||
plan.highlight
|
||||
? 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-400 dark:text-stone-900 dark:hover:bg-orange-300'
|
||||
: 'bg-blue-900 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400'
|
||||
}`}
|
||||
>
|
||||
{dict.getStarted}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-center text-sm text-stone-400 dark:text-stone-500">{dict.footer}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import PricingClient from './PricingClient'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Pricing',
|
||||
title: 'One platform, every fleet size.',
|
||||
body: 'Start free for 90 days, then choose the plan that grows with your business. Switch plans or cancel at any time.',
|
||||
faq: 'Frequently asked',
|
||||
items: [
|
||||
['Can I change plans later?', 'Yes. Upgrade or downgrade at any time — changes take effect on your next billing cycle.'],
|
||||
['What counts as a vehicle?', 'Any vehicle record added to your fleet dashboard, active or not.'],
|
||||
['Is there a setup fee?', 'No setup fee, ever. You only pay the monthly or annual subscription.'],
|
||||
['How does the marketplace listing work?', 'All paid plans include a public marketplace listing on RentalDriveGo. Renters can discover your fleet and are redirected to your branded booking site.'],
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Tarifs',
|
||||
title: 'Une seule plateforme pour toutes les tailles de flotte.',
|
||||
body: 'Commencez gratuitement pendant 90 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
|
||||
faq: 'Questions fréquentes',
|
||||
items: [
|
||||
['Puis-je changer de formule plus tard ?', 'Oui. Vous pouvez passer à une formule supérieure ou inférieure à tout moment. Les changements prennent effet au cycle de facturation suivant.'],
|
||||
["Qu'est-ce qui compte comme véhicule ?", "Tout véhicule ajouté à votre tableau de bord de flotte, qu'il soit actif ou non."],
|
||||
["Y a-t-il des frais d'installation ?", "Aucun frais d'installation. Vous payez uniquement l'abonnement mensuel ou annuel."],
|
||||
['Comment fonctionne la visibilité marketplace ?', 'Toutes les formules payantes incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'الأسعار',
|
||||
title: 'منصة واحدة لكل أحجام الأساطيل.',
|
||||
body: 'ابدأ مجاناً لمدة 14 يوماً، ثم اختر الخطة التي تناسب نمو نشاطك. يمكنك تغيير الخطة أو إلغاءها في أي وقت.',
|
||||
faq: 'الأسئلة الشائعة',
|
||||
items: [
|
||||
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
|
||||
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
|
||||
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط قيمة الاشتراك الشهري أو السنوي.'],
|
||||
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function PricingPageContent({ initialPricing }: { initialPricing?: React.ComponentProps<typeof PricingClient>['initialPricing'] }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="site-kicker">{dict.kicker}</p>
|
||||
<h1 className="site-title">{dict.title}</h1>
|
||||
<p className="site-lead">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<PricingClient initialPricing={initialPricing} />
|
||||
</div>
|
||||
|
||||
<div className="site-panel mx-auto mt-24 max-w-3xl divide-y divide-stone-200/80 dark:divide-stone-800">
|
||||
<h2 className="pb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.faq}</h2>
|
||||
{dict.items.map(([q, a]) => (
|
||||
<div key={q} className="py-6">
|
||||
<p className="font-semibold text-stone-900 dark:text-stone-100">{q}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-stone-400">{a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import PricingPageContent from './PricingPageContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — RentalDriveGo',
|
||||
description: 'Simple, transparent pricing for every fleet size.',
|
||||
}
|
||||
|
||||
const DEFAULT_PRICING = {
|
||||
prices: PLAN_PRICES,
|
||||
planFeatures: PLAN_FEATURES,
|
||||
}
|
||||
|
||||
export default async function PricingPage() {
|
||||
const initialPricing = await marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
|
||||
return <PricingPageContent initialPricing={initialPricing} />
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Star } from 'lucide-react'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface ReviewInfo {
|
||||
reservationId: string
|
||||
companyName: string
|
||||
companyLogoUrl: string | null
|
||||
vehicle: string
|
||||
vehiclePhoto: string | null
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
|
||||
const [hovered, setHovered] = useState(0)
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">{label}</p>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange(n)}
|
||||
onMouseEnter={() => setHovered(n)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
className="p-0.5 transition-transform hover:scale-110"
|
||||
aria-label={`${n} star${n > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
n <= (hovered || value)
|
||||
? 'fill-orange-400 text-orange-400'
|
||||
: 'fill-stone-200 text-stone-200 dark:fill-stone-700 dark:text-stone-700'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewPageContent() {
|
||||
const params = useSearchParams()
|
||||
const token = params.get('token') ?? ''
|
||||
|
||||
const [info, setInfo] = useState<ReviewInfo | null>(null)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
|
||||
|
||||
const [overall, setOverall] = useState(0)
|
||||
const [vehicle, setVehicle] = useState(0)
|
||||
const [service, setService] = useState(0)
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setLoadState('invalid'); return }
|
||||
fetch(`${API_BASE}/marketplace/review/${token}`)
|
||||
.then(async (r) => {
|
||||
if (r.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!r.ok) { setLoadState('invalid'); return }
|
||||
const json = await r.json()
|
||||
setInfo(json.data)
|
||||
setLoadState('ready')
|
||||
})
|
||||
.catch(() => setLoadState('invalid'))
|
||||
}, [token])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
|
||||
setSubmitState('submitting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
overallRating: overall,
|
||||
vehicleRating: vehicle || undefined,
|
||||
serviceRating: service || undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
if (res.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
throw new Error(json.message ?? 'Something went wrong.')
|
||||
}
|
||||
setSubmitState('done')
|
||||
} catch (err: any) {
|
||||
setSubmitState('error')
|
||||
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
if (loadState === 'loading') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">Loading…</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'invalid') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-3 p-8 text-center">
|
||||
<p className="text-2xl font-black text-stone-900 dark:text-stone-100">Link not found</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">This review link is invalid or has already been used.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'already_reviewed') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-3 p-8 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-7 w-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-stone-900 dark:text-stone-100">Already reviewed</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">You have already submitted a review for this rental. Thank you!</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitState === 'done') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-4 p-8 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-8 w-8 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-stone-900 dark:text-stone-100">Thank you!</h1>
|
||||
<p className="text-stone-500 dark:text-stone-400">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 border-b border-stone-200 dark:border-blue-900 p-6">
|
||||
{info?.vehiclePhoto ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 flex-shrink-0 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-24 flex-shrink-0 rounded-xl bg-stone-100 dark:bg-blue-900/30" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs uppercase tracking-widest text-stone-400 dark:text-stone-500">{info?.companyName}</p>
|
||||
<h1 className="mt-0.5 truncate text-lg font-bold text-stone-900 dark:text-stone-100">{info?.vehicle}</h1>
|
||||
<p className="mt-0.5 text-sm text-stone-500 dark:text-stone-400">How was your experience?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
|
||||
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
|
||||
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">Comments (optional)</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Tell us about your rental experience…"
|
||||
className="w-full resize-none rounded-xl border border-stone-200 dark:border-blue-800 bg-white dark:bg-blue-950 px-4 py-3 text-sm text-stone-900 dark:text-stone-100 placeholder:text-stone-400 dark:placeholder:text-stone-500 focus:outline-none focus:ring-2 focus:ring-stone-900 dark:focus:ring-stone-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<p className="rounded-xl bg-rose-50 dark:bg-rose-950/40 px-4 py-3 text-sm text-rose-700 dark:text-rose-400">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitState === 'submitting'}
|
||||
className="w-full rounded-full bg-orange-600 py-3 text-sm font-semibold text-white transition-colors hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
|
||||
>
|
||||
{submitState === 'submitting' ? 'Submitting…' : 'Submit review'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">Loading…</p>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<ReviewPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { StateAction, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
||||
import { SignInForm } from '@/components/auth/SignInForm';
|
||||
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm';
|
||||
import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
|
||||
import {
|
||||
isLocale,
|
||||
localizedPath,
|
||||
routeIdFromSlug,
|
||||
type Locale,
|
||||
type RouteId,
|
||||
} from '@/lib/localization/config';
|
||||
import { getMessages, type ShellMessages } from '@/lib/localization/messages';
|
||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface PendingRouteProps {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}
|
||||
|
||||
function routeLabel(messages: ShellMessages, routeId: RouteId): string {
|
||||
const labels = messages.footer.links;
|
||||
switch (routeId) {
|
||||
case 'sign-in':
|
||||
return labels.login;
|
||||
case 'forgot-password':
|
||||
return 'Forgot password';
|
||||
case 'reset-password':
|
||||
return 'Reset password';
|
||||
case 'privacy':
|
||||
return labels.privacy;
|
||||
case 'terms':
|
||||
return labels.terms;
|
||||
case 'accessibility':
|
||||
return labels.accessibility;
|
||||
case 'home':
|
||||
return 'RentalDriveGo';
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveParams(params: PendingRouteProps['params']) {
|
||||
const { locale: localeValue, slug } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const routeId = routeIdFromSlug(localeValue, slug);
|
||||
if (!routeId || routeId === 'home') notFound();
|
||||
return { locale: localeValue as Locale, routeId };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PendingRouteProps): Promise<Metadata> {
|
||||
const { locale, routeId } = await resolveParams(params);
|
||||
const messages = getMessages(locale).shell;
|
||||
const label = routeLabel(messages, routeId);
|
||||
return buildLocalizedMetadata({
|
||||
locale,
|
||||
routeId,
|
||||
title: `${label} | ${messages.metadata.pendingTitle}`,
|
||||
description: messages.metadata.pendingDescription,
|
||||
indexable: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PendingRoute({ params }: PendingRouteProps) {
|
||||
const { locale, routeId } = await resolveParams(params);
|
||||
const messages = getMessages(locale).shell;
|
||||
const label = routeLabel(messages, routeId);
|
||||
|
||||
if (routeId === 'sign-in') {
|
||||
return <SignInForm locale={locale} />;
|
||||
}
|
||||
|
||||
if (routeId === 'forgot-password') {
|
||||
return <ForgotPasswordForm locale={locale} />;
|
||||
}
|
||||
|
||||
if (routeId === 'reset-password') {
|
||||
return <ResetPasswordForm locale={locale} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<StateShell
|
||||
title={`${label}: ${messages.states.pendingTitle}`}
|
||||
body={messages.states.pendingBody}
|
||||
>
|
||||
<StateActions>
|
||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
||||
</StateActions>
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import { Accordion } from '@/components/controls/Accordion';
|
||||
import { SegmentedControl } from '@/components/controls/SegmentedControl';
|
||||
import { Tabs } from '@/components/controls/Tabs';
|
||||
import { Alert } from '@/components/feedback/Alert';
|
||||
import { Checkbox, Switch } from '@/components/forms/ChoiceControls';
|
||||
import { FormField } from '@/components/forms/FormField';
|
||||
import { TextInput } from '@/components/forms/TextInput';
|
||||
import { DropdownMenu } from '@/components/overlays/DropdownMenu';
|
||||
import { Dialog } from '@/components/overlays/Dialog';
|
||||
import type { ComponentLabCopy } from '@/content/development/component-lab';
|
||||
import { useRef, useState } from 'react';
|
||||
import styles from './page.module.css';
|
||||
|
||||
export function ComponentLabInteractive({ copy }: { copy: ComponentLabCopy }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [segment, setSegment] = useState('comfortable');
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<div className={styles.interactiveGrid}>
|
||||
<section className={styles.group} aria-labelledby="lab-actions">
|
||||
<h2 id="lab-actions">Actions</h2>
|
||||
<div className={styles.cluster}>
|
||||
<Button>{copy.actions.primary}</Button>
|
||||
<Button intent="conversion">{copy.actions.conversion}</Button>
|
||||
<Button loading>{copy.actions.loading}</Button>
|
||||
<Button disabled>{copy.actions.disabled}</Button>
|
||||
<Button ref={triggerRef} intent="secondary" onClick={() => setDialogOpen(true)}>
|
||||
{copy.dialog.open}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
label={copy.menu.label}
|
||||
items={[
|
||||
{ id: 'review', label: copy.menu.first, onSelect: () => undefined },
|
||||
{
|
||||
id: 'archive',
|
||||
label: copy.menu.second,
|
||||
onSelect: () => undefined,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.group} aria-labelledby="lab-form">
|
||||
<h2 id="lab-form">Form controls</h2>
|
||||
<FormField
|
||||
id="fixture-email"
|
||||
label={copy.form.label}
|
||||
supportingText={copy.form.supporting}
|
||||
error={copy.form.error}
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
id="fixture-email"
|
||||
type="email"
|
||||
placeholder={copy.form.placeholder}
|
||||
invalid
|
||||
describedBy="fixture-email-help fixture-email-error"
|
||||
/>
|
||||
</FormField>
|
||||
<Checkbox label={copy.form.checkbox} />
|
||||
<Switch label={copy.form.switch} />
|
||||
<SegmentedControl
|
||||
label="Density"
|
||||
value={segment}
|
||||
onChange={setSegment}
|
||||
options={[
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<section className={styles.group} aria-labelledby="lab-controls">
|
||||
<h2 id="lab-controls">Selection and disclosure</h2>
|
||||
<Tabs label={copy.tabs.label} items={copy.tabs.items} />
|
||||
<Accordion items={copy.accordion.items} />
|
||||
</section>
|
||||
<Alert tone="warning" title={copy.alert.title}>
|
||||
{copy.alert.body}
|
||||
</Alert>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={copy.dialog.title}
|
||||
description={copy.dialog.description}
|
||||
closeLabel={copy.dialog.close}
|
||||
returnFocusRef={triggerRef}
|
||||
>
|
||||
<p>{copy.dialog.content}</p>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.main {
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
.interactiveGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-4);
|
||||
min-inline-size: 0;
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--border-standard);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.group h2 {
|
||||
margin: 0;
|
||||
font-size: var(--type-heading-3-size);
|
||||
}
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.interactiveGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
||||
import { Comparison } from '@/components/marketing/Comparison';
|
||||
import { CTA } from '@/components/marketing/CTA';
|
||||
import { Metric } from '@/components/marketing/Evidence';
|
||||
import { ProductPreview } from '@/components/marketing/ProductPreview';
|
||||
import { SectionHeader } from '@/components/marketing/SectionHeader';
|
||||
import { Workflow } from '@/components/marketing/Workflow';
|
||||
import { getComponentLabCopy } from '@/content/development/component-lab';
|
||||
import { isLocale, type Locale } from '@/lib/localization/config';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ComponentLabInteractive } from './ComponentLabInteractive';
|
||||
import styles from './page.module.css';
|
||||
|
||||
export const metadata = { robots: { index: false, follow: false } };
|
||||
|
||||
export default async function ComponentLabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
if (process.env.COMPONENT_FIXTURES_ENABLED !== 'true') notFound();
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const resolvedLocale: Locale = locale;
|
||||
const copy = getComponentLabCopy(resolvedLocale);
|
||||
return (
|
||||
<main id="main-content" className={styles.main}>
|
||||
<Section>
|
||||
<Container>
|
||||
<Stack gap="large">
|
||||
<SectionHeader
|
||||
eyebrow={copy.developmentLabel}
|
||||
title={copy.title}
|
||||
body={copy.body}
|
||||
headingLevel={1}
|
||||
/>
|
||||
<ComponentLabInteractive copy={copy} />
|
||||
<Workflow label={copy.title} items={copy.workflow} />
|
||||
<Comparison
|
||||
columns={[
|
||||
{
|
||||
title: copy.comparison.beforeTitle,
|
||||
items: copy.comparison.before,
|
||||
tone: 'problem',
|
||||
},
|
||||
{
|
||||
title: copy.comparison.afterTitle,
|
||||
items: copy.comparison.after,
|
||||
tone: 'solution',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProductPreview
|
||||
title={copy.preview.title}
|
||||
caption={copy.preview.caption}
|
||||
illustrativeLabel={copy.preview.illustrative}
|
||||
rows={copy.preview.rows.map((row, index) => ({
|
||||
...row,
|
||||
statusTone: index === 0 ? 'success' : 'warning',
|
||||
}))}
|
||||
/>
|
||||
<Metric
|
||||
content={{
|
||||
value: copy.metric.value,
|
||||
label: copy.metric.label,
|
||||
qualification: copy.metric.qualification,
|
||||
status: 'research-only',
|
||||
}}
|
||||
statusLabel={copy.metric.status}
|
||||
/>
|
||||
<CTA
|
||||
title={copy.cta.title}
|
||||
body={copy.cta.body}
|
||||
primary={{ label: copy.cta.primary, disabledReason: copy.cta.unavailable }}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { StateButton, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function ErrorPage({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const params = useParams<{ locale?: string }>();
|
||||
const messages = getClientShellMessages(params?.locale);
|
||||
|
||||
return (
|
||||
<StateShell title={messages.states.errorTitle} body={messages.states.errorBody}>
|
||||
<StateActions>
|
||||
<StateButton onClick={reset}>{messages.states.retry}</StateButton>
|
||||
</StateActions>
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import '@fontsource-variable/inter/index.css';
|
||||
import '@fontsource-variable/noto-sans-arabic/index.css';
|
||||
import '@/styles/tokens.css';
|
||||
import '@/styles/component-tokens.css';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
import { SiteFooter } from '@/components/app-shell/SiteFooter';
|
||||
import { DemoDialogHost } from '@/components/integrations/DemoDialogHost';
|
||||
import { SiteHeader } from '@/components/app-shell/SiteHeader';
|
||||
import { ThemeController } from '@/components/app-shell/ThemeController';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { getDirection, isLocale, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
|
||||
import {
|
||||
isThemePreference,
|
||||
serverResolvedTheme,
|
||||
themeCookie,
|
||||
type ThemePreference,
|
||||
} from '@/lib/theme/config';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
icons: {
|
||||
icon: '/rentaldrivego.jpeg',
|
||||
apple: '/rentaldrivego.jpeg',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface LocaleLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const requestHeaders = await headers();
|
||||
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
||||
const cookieStore = await cookies();
|
||||
const cookiePreference = cookieStore.get(themeCookie)?.value;
|
||||
const preference: ThemePreference = isThemePreference(cookiePreference)
|
||||
? cookiePreference
|
||||
: 'system';
|
||||
const resolvedTheme = serverResolvedTheme(preference);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={getDirection(locale)}
|
||||
data-theme-preference={preference}
|
||||
data-theme={resolvedTheme}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
id="theme-bootstrap"
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript }}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<ThemeController />
|
||||
<a className="skip-link" href="#main-content">
|
||||
{messages.shell.skipToContent}
|
||||
</a>
|
||||
<SiteHeader
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
themePreference={preference}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
{children}
|
||||
<DemoDialogHost
|
||||
locale={locale}
|
||||
messages={messages.homepage.form}
|
||||
enabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<SiteFooter
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LocalizedLoading } from '@/components/app-shell/LocalizedLoading';
|
||||
|
||||
export default function Loading() {
|
||||
return <LocalizedLoading />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LocalizedNotFound } from '@/components/app-shell/LocalizedNotFound';
|
||||
|
||||
export default function NotFound() {
|
||||
return <LocalizedNotFound />;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import styles from '@/components/homepage/Homepage.module.css';
|
||||
import {
|
||||
ComparisonSection,
|
||||
FaqSection,
|
||||
FinalCtaSection,
|
||||
HeroSection,
|
||||
IntegrationsSection,
|
||||
ModulesSection,
|
||||
PricingSection,
|
||||
ResultsSection,
|
||||
RolesSection,
|
||||
SecuritySection,
|
||||
TrustSection,
|
||||
WorkflowSection,
|
||||
} from '@/components/homepage/sections';
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { isLocale, localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface LocalePageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: LocalePageProps): Promise<Metadata> {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const messages = getMessages(localeValue);
|
||||
return buildLocalizedMetadata({
|
||||
locale: localeValue,
|
||||
routeId: 'home',
|
||||
title: messages.homepage.meta.title,
|
||||
description: messages.homepage.meta.description,
|
||||
indexable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function LocaleHomePage({ params }: LocalePageProps) {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const content = buildHomepageContent(messages.homepage, messages.shell);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const homePath = localizedPath('home', locale);
|
||||
|
||||
return (
|
||||
<main id="main-content" className={styles.main} tabIndex={-1}>
|
||||
<HeroSection
|
||||
content={content.hero}
|
||||
homePath={homePath}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<TrustSection content={content.trust} />
|
||||
<ComparisonSection content={content.comparison} />
|
||||
<WorkflowSection content={content.workflow} />
|
||||
<RolesSection content={content.roles} />
|
||||
<ModulesSection content={content.modules} />
|
||||
<ResultsSection content={content.results} />
|
||||
<IntegrationsSection content={content.integrations} />
|
||||
<SecuritySection content={content.security} />
|
||||
<PricingSection
|
||||
content={content.pricing}
|
||||
locale={locale}
|
||||
homePath={homePath}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<FaqSection content={content.faq} />
|
||||
<FinalCtaSection
|
||||
content={content.final}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
demoSubmissionEnvelopeSchema,
|
||||
zodFieldErrors,
|
||||
type DemoSubmissionResult,
|
||||
} from '@/lib/demo/schema';
|
||||
import { submitDemoLead } from '@/lib/demo/service';
|
||||
import { getIntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { isApplicationJson } from '@/lib/security/content-type';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const maximumBodyBytes = 16_384;
|
||||
const responseHeaders = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
||||
|
||||
function failure(
|
||||
status: number,
|
||||
result: Extract<DemoSubmissionResult, { ok: false }>,
|
||||
): NextResponse<DemoSubmissionResult> {
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
function isAllowedOrigin(
|
||||
request: Request,
|
||||
approvedOrigin: string | undefined,
|
||||
production: boolean,
|
||||
) {
|
||||
const origin = request.headers.get('origin');
|
||||
if (!origin) return !production;
|
||||
const requestOrigin = new URL(request.url).origin;
|
||||
if (origin === requestOrigin) return true;
|
||||
return approvedOrigin ? origin === new URL(approvedOrigin).origin : false;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse<DemoSubmissionResult>> {
|
||||
const correlationId = randomUUID();
|
||||
let environment;
|
||||
try {
|
||||
environment = getIntegrationEnvironment();
|
||||
} catch {
|
||||
return failure(503, {
|
||||
ok: false,
|
||||
category: 'configuration',
|
||||
formErrorCode: 'integration_environment_invalid',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.headers.get('x-rdg-form') !== 'demo-v1') {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_request_marker',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(request, environment.SITE_ORIGIN, environment.NODE_ENV === 'production')) {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_origin',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (!isApplicationJson(contentType)) {
|
||||
return failure(415, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'unsupported_content_type',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const declaredLength = Number(request.headers.get('content-length') ?? '0');
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (new TextEncoder().encode(raw).byteLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'malformed_json',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = demoSubmissionEnvelopeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
fieldErrors: zodFieldErrors(parsed.error),
|
||||
formErrorCode: 'validation_failed',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.guard.honeypot.length > 0) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'abuse',
|
||||
formErrorCode: 'request_rejected',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const scenarioHeader = request.headers.get('x-rdg-test-scenario');
|
||||
const testScenario =
|
||||
environment.NODE_ENV !== 'production' &&
|
||||
environment.DEMO_TEST_SCENARIOS_ENABLED &&
|
||||
['success', 'duplicate', 'timeout', 'integration-error'].includes(scenarioHeader ?? '')
|
||||
? (scenarioHeader as 'success' | 'duplicate' | 'timeout' | 'integration-error')
|
||||
: undefined;
|
||||
|
||||
const result = await submitDemoLead(parsed.data.lead, {
|
||||
environment,
|
||||
...(testScenario ? { testScenario } : {}),
|
||||
});
|
||||
|
||||
const status = result.ok
|
||||
? 202
|
||||
: result.category === 'timeout'
|
||||
? 504
|
||||
: result.category === 'configuration'
|
||||
? 503
|
||||
: result.category === 'duplicate'
|
||||
? 409
|
||||
: 502;
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
html.dark .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
.site-section {
|
||||
@apply shell py-10 sm:py-14 lg:py-16;
|
||||
}
|
||||
|
||||
.site-panel {
|
||||
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .site-panel {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-image: linear-gradient(160deg, #f5f8ff 0%, #edf2ff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-image: linear-gradient(160deg, #0c1830 0%, #10203e 100%);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
|
||||
border-color: rgb(14 40 90 / 0.8);
|
||||
background-color: rgb(10 25 75);
|
||||
}
|
||||
|
||||
html.dark .site-panel-contrast {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
|
||||
}
|
||||
|
||||
.site-lead {
|
||||
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
|
||||
}
|
||||
|
||||
.site-link-primary {
|
||||
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.site-link-secondary {
|
||||
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import MarketplaceShell from '@/components/MarketplaceShell'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FleetOS Marketplace',
|
||||
description: 'Discover vehicles from trusted rental companies.',
|
||||
icons: {
|
||||
icon: '/rentalcardrive.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentalcardrive.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getMarketplaceLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme =
|
||||
cookieStore.get('rentaldrivego-theme')?.value ??
|
||||
cookieStore.get('marketplace-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('marketplace-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<MarketplaceShell initialLanguage={language} initialTheme={theme}>{children}</MarketplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getSiteOrigin } from '@/lib/localization/site-origin';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const origin = getSiteOrigin();
|
||||
const approved = process.env.PUBLIC_RELEASE_APPROVED === 'true';
|
||||
return {
|
||||
rules: approved ? { userAgent: '*', allow: '/' } : { userAgent: '*', disallow: '/' },
|
||||
host: origin.href,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user