add homepage management
This commit is contained in:
@@ -1,184 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'Marketplace discovery with a sharper front door.',
|
||||
heroBody:
|
||||
'Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.',
|
||||
startTrial: 'Start free trial',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
surfaceLabel: 'Marketplace surface',
|
||||
surfaceTitle: 'Designed for two audiences at once.',
|
||||
surfaceBody:
|
||||
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
|
||||
liveLabel: 'Live network',
|
||||
trustedFleets: 'Trusted fleets',
|
||||
brandedFlows: 'Branded booking flows',
|
||||
multiTenant: 'Multi-tenant operations',
|
||||
companyKicker: 'Operator workflow',
|
||||
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.',
|
||||
companyBody:
|
||||
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
|
||||
renterKicker: 'Renter experience',
|
||||
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
|
||||
renterBody:
|
||||
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.',
|
||||
pillars: [
|
||||
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
|
||||
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
|
||||
['Direct revenue path', 'Bookings move into the company’s own payment flow, so customer ownership and cash collection stay with the business.'],
|
||||
],
|
||||
metrics: [
|
||||
['01', 'Shared marketplace visibility'],
|
||||
['02', 'Direct company checkout'],
|
||||
['03', 'Company-owned payments'],
|
||||
],
|
||||
featureLabel: 'What the product covers',
|
||||
features: [
|
||||
'Fleet management with multi-photo uploads',
|
||||
'Marketplace offers and redirect booking flow',
|
||||
'Branded public booking site per company',
|
||||
'Customer CRM, analytics, and billing controls',
|
||||
],
|
||||
stepsTitle: 'How companies launch',
|
||||
steps: [
|
||||
['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'],
|
||||
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
|
||||
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
|
||||
],
|
||||
stepLabel: 'Step',
|
||||
readyKicker: 'Ready to launch',
|
||||
readyTitle: 'A marketplace homepage should sell the system in seconds.',
|
||||
readyBody:
|
||||
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.',
|
||||
viewPricing: 'View pricing',
|
||||
createWorkspace: 'Create workspace',
|
||||
},
|
||||
fr: {
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'Une vitrine marketplace plus nette et plus forte.',
|
||||
heroBody:
|
||||
'Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.',
|
||||
startTrial: 'Commencer l’essai gratuit',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
surfaceLabel: 'Surface marketplace',
|
||||
surfaceTitle: 'Pensée pour deux audiences à la fois.',
|
||||
surfaceBody:
|
||||
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
|
||||
liveLabel: 'Réseau actif',
|
||||
trustedFleets: 'Flottes fiables',
|
||||
brandedFlows: 'Parcours de marque',
|
||||
multiTenant: 'Opérations multi-tenant',
|
||||
companyKicker: 'Flux opérateur',
|
||||
companyTitle: 'Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.',
|
||||
companyBody:
|
||||
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
|
||||
renterKicker: 'Expérience client',
|
||||
renterTitle: 'Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.',
|
||||
renterBody:
|
||||
'La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
|
||||
pillars: [
|
||||
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'],
|
||||
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
|
||||
['Revenus en direct', 'Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement.'],
|
||||
],
|
||||
metrics: [
|
||||
['01', 'Visibilité marketplace partagée'],
|
||||
['02', 'Paiement direct entreprise'],
|
||||
['03', 'Paiements détenus par la société'],
|
||||
],
|
||||
featureLabel: 'Ce que couvre le produit',
|
||||
features: [
|
||||
'Gestion de flotte avec téléversement multi-photos',
|
||||
'Offres marketplace et redirection vers la réservation',
|
||||
'Site public de réservation par entreprise',
|
||||
'CRM client, analytics et contrôle de facturation',
|
||||
],
|
||||
stepsTitle: 'Comment les entreprises démarrent',
|
||||
steps: [
|
||||
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'],
|
||||
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'],
|
||||
['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.'],
|
||||
],
|
||||
stepLabel: 'Étape',
|
||||
readyKicker: 'Prêt à démarrer',
|
||||
readyTitle: 'Une homepage marketplace doit vendre le système en quelques secondes.',
|
||||
readyBody:
|
||||
'Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.',
|
||||
viewPricing: 'Voir les tarifs',
|
||||
createWorkspace: 'Créer l’espace',
|
||||
},
|
||||
ar: {
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'واجهة سوق أوضح وأقوى.',
|
||||
heroBody:
|
||||
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.',
|
||||
startTrial: 'ابدأ التجربة المجانية',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
surfaceLabel: 'واجهة السوق',
|
||||
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
|
||||
surfaceBody:
|
||||
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
|
||||
liveLabel: 'شبكة نشطة',
|
||||
trustedFleets: 'أساطيل موثوقة',
|
||||
brandedFlows: 'مسارات حجز مخصصة',
|
||||
multiTenant: 'عمليات متعددة الشركات',
|
||||
companyKicker: 'تدفق المشغل',
|
||||
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
|
||||
companyBody:
|
||||
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
|
||||
renterKicker: 'تجربة المستأجر',
|
||||
renterTitle: 'دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.',
|
||||
renterBody:
|
||||
'السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.',
|
||||
pillars: [
|
||||
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
|
||||
['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'],
|
||||
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'],
|
||||
],
|
||||
metrics: [
|
||||
['01', 'ظهور مشترك في السوق'],
|
||||
['02', 'دفع مباشر للشركة'],
|
||||
['03', 'مدفوعات مملوكة للشركة'],
|
||||
],
|
||||
featureLabel: 'ما الذي يغطيه المنتج',
|
||||
features: [
|
||||
'إدارة الأسطول مع رفع عدة صور',
|
||||
'عروض السوق والتحويل إلى مسار الحجز',
|
||||
'موقع حجز عام مخصص لكل شركة',
|
||||
'إدارة العملاء والتحليلات والفوترة',
|
||||
],
|
||||
stepsTitle: 'كيف تبدأ الشركات',
|
||||
steps: [
|
||||
['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'],
|
||||
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
|
||||
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
|
||||
],
|
||||
stepLabel: 'الخطوة',
|
||||
readyKicker: 'جاهز للانطلاق',
|
||||
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
|
||||
readyBody:
|
||||
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.',
|
||||
viewPricing: 'عرض الأسعار',
|
||||
createWorkspace: 'إنشاء المساحة',
|
||||
},
|
||||
} as const
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function HomeContent() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const dict = content[language]
|
||||
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
||||
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
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-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] dark:bg-[linear-gradient(180deg,#14110f_0%,#17120d_35%,#0c0a09_100%)]">
|
||||
<section className="relative">
|
||||
<div className="absolute inset-x-0 top-0 h-[38rem] bg-[radial-gradient(circle_at_top_left,rgba(217,119,6,0.24),transparent_34%),radial-gradient(circle_at_top_right,rgba(15,118,110,0.18),transparent_26%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(45,212,191,0.14),transparent_26%)]" />
|
||||
<div className="shell relative py-10 sm:py-14 lg:py-20">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]">
|
||||
<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-stone-800 dark:bg-stone-900/70">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700 dark:text-amber-300">{dict.heroKicker}</p>
|
||||
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-stone-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
|
||||
@@ -204,7 +76,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:grid-cols-3">
|
||||
{dict.metrics.map(([value, label]) => (
|
||||
{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-stone-800 dark:bg-stone-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>
|
||||
@@ -212,7 +84,9 @@ export default function HomeContent() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasSection('surface') ? (
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-stone-950 p-6 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] dark:border-stone-700">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(245,158,11,0.34),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(20,184,166,0.25),transparent_32%)]" />
|
||||
<div className="relative">
|
||||
@@ -247,9 +121,10 @@ export default function HomeContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{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-stone-800 dark:bg-stone-900/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-stone-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
|
||||
@@ -260,13 +135,13 @@ export default function HomeContent() {
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-stone-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>
|
||||
</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shell pb-10">
|
||||
{hasSection('pillars') ? <section className="shell pb-10">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{dict.pillars.map(([title, body], index) => (
|
||||
{dict.pillars.map(({ title, body }, index) => (
|
||||
<article
|
||||
key={title}
|
||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||
@@ -293,10 +168,11 @@ export default function HomeContent() {
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section> : null}
|
||||
|
||||
<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]">
|
||||
{hasSection('features') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#fffdf8_0%,#f1ebe1_100%)] p-8 dark:border-stone-800 dark:bg-[linear-gradient(160deg,#17120d_0%,#221a14_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">
|
||||
@@ -310,7 +186,9 @@ export default function HomeContent() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div />}
|
||||
|
||||
{hasSection('steps') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-stone-800 dark:bg-stone-900">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
@@ -320,7 +198,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{dict.steps.map(([step, title, body]) => (
|
||||
{dict.steps.map(({ step, title, body }) => (
|
||||
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-stone-800 dark:bg-stone-950">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
|
||||
{dict.stepLabel} {step}
|
||||
@@ -331,10 +209,11 @@ export default function HomeContent() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
</section>
|
||||
</section> : null}
|
||||
|
||||
<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-stone-200 bg-stone-950 px-8 py-10 text-white dark:border-stone-700">
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
@@ -359,7 +238,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameConfig: Record<FrameId, { label: string; port: number; path: string }
|
||||
dashboard: {
|
||||
label: 'Company Workspace',
|
||||
port: 3001,
|
||||
path: '/',
|
||||
path: '/sign-in',
|
||||
},
|
||||
admin: {
|
||||
label: 'Platform Operations',
|
||||
|
||||
Reference in New Issue
Block a user