add homepage management

This commit is contained in:
root
2026-05-07 00:34:29 -04:00
parent 750ae56a29
commit c4a45c8b21
33 changed files with 4228 additions and 418 deletions
+56 -177
View File
@@ -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 companys 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 companys 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 lessai 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 à lentreprise, pour garder la relation client et lencaissement.'],
],
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 lespace',
},
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&currency=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',