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
+3
View File
@@ -1,3 +1,6 @@
export * from './damage'
export * from './fuel'
export * from './api'
export * from './marketplace-homepage'
export * from './public-homepage-layout'
export * from './public-site-sections'
+251
View File
@@ -0,0 +1,251 @@
export type MarketplaceLanguage = 'en' | 'fr' | 'ar'
export type MarketplaceHomepageMetric = {
value: string
label: string
}
export type MarketplaceHomepagePillar = {
title: string
body: string
}
export type MarketplaceHomepageStep = {
step: string
title: string
body: string
}
export type MarketplaceHomepageSectionType =
| 'hero'
| 'surface'
| 'pillars'
| 'audiences'
| 'features'
| 'steps'
| 'closing'
export type MarketplaceHomepageContent = {
sections: MarketplaceHomepageSectionType[]
heroKicker: string
heroTitle: string
heroBody: string
startTrial: string
exploreVehicles: string
surfaceLabel: string
surfaceTitle: string
surfaceBody: string
liveLabel: string
trustedFleets: string
brandedFlows: string
multiTenant: string
companyKicker: string
companyTitle: string
companyBody: string
renterKicker: string
renterTitle: string
renterBody: string
pillars: MarketplaceHomepagePillar[]
metrics: MarketplaceHomepageMetric[]
featureLabel: string
features: string[]
stepsTitle: string
steps: MarketplaceHomepageStep[]
stepLabel: string
readyKicker: string
readyTitle: string
readyBody: string
viewPricing: string
createWorkspace: string
}
export type MarketplaceHomepageConfig = Record<MarketplaceLanguage, MarketplaceHomepageContent>
export const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[] = [
'hero',
'surface',
'pillars',
'audiences',
'features',
'steps',
'closing',
]
export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
en: {
sections: defaultMarketplaceHomepageSections,
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.'],
].map(([title, body]) => ({ title, body })),
metrics: [
['01', 'Shared marketplace visibility'],
['02', 'Direct company checkout'],
['03', 'Company-owned payments'],
].map(([value, label]) => ({ value, label })),
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.'],
].map(([step, title, body]) => ({ step, title, body })),
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: {
sections: defaultMarketplaceHomepageSections,
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.'],
].map(([title, body]) => ({ title, body })),
metrics: [
['01', 'Visibilité marketplace partagée'],
['02', 'Paiement direct entreprise'],
['03', 'Paiements détenus par la société'],
].map(([value, label]) => ({ value, label })),
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.'],
].map(([step, title, body]) => ({ step, title, body })),
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: {
sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'واجهة سوق أوضح وأقوى.',
heroBody:
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.',
startTrial: 'ابدأ التجربة المجانية',
exploreVehicles: 'استكشف السيارات',
surfaceLabel: 'واجهة السوق',
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
surfaceBody:
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
liveLabel: 'شبكة نشطة',
trustedFleets: 'أساطيل موثوقة',
brandedFlows: 'مسارات حجز مخصصة',
multiTenant: 'عمليات متعددة الشركات',
companyKicker: 'تدفق المشغل',
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
companyBody:
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
renterKicker: 'تجربة المستأجر',
renterTitle: 'دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.',
renterBody:
'السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.',
pillars: [
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'],
].map(([title, body]) => ({ title, body })),
metrics: [
['01', 'ظهور مشترك في السوق'],
['02', 'دفع مباشر للشركة'],
['03', 'مدفوعات مملوكة للشركة'],
].map(([value, label]) => ({ value, label })),
featureLabel: 'ما الذي يغطيه المنتج',
features: [
'إدارة الأسطول مع رفع عدة صور',
'عروض السوق والتحويل إلى مسار الحجز',
'موقع حجز عام مخصص لكل شركة',
'إدارة العملاء والتحليلات والفوترة',
],
stepsTitle: 'كيف تبدأ الشركات',
steps: [
['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
].map(([step, title, body]) => ({ step, title, body })),
stepLabel: 'الخطوة',
readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyBody:
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.',
viewPricing: 'عرض الأسعار',
createWorkspace: 'إنشاء المساحة',
},
}
export function cloneMarketplaceHomepageContent(): MarketplaceHomepageConfig {
return JSON.parse(JSON.stringify(defaultMarketplaceHomepageContent)) as MarketplaceHomepageConfig
}
export function resolveMarketplaceHomepageSections(
sections?: MarketplaceHomepageSectionType[] | null,
): MarketplaceHomepageSectionType[] {
const source = sections?.length ? sections : defaultMarketplaceHomepageSections
return source.filter((section, index) => source.indexOf(section) === index)
}
@@ -0,0 +1,87 @@
export const PUBLIC_HOMEPAGE_GRID_COLUMNS = 12
export const PUBLIC_HOMEPAGE_GRID_ROWS = 12
export type PublicHomepageSectionType = 'hero' | 'offers' | 'vehicles' | 'pricing'
export type PublicHomepageLayoutItem = {
id: string
type: PublicHomepageSectionType
x: number
y: number
w: number
h: number
}
export type PublicHomepageLayout = {
items: PublicHomepageLayoutItem[]
}
export const defaultPublicHomepageLayout: PublicHomepageLayout = {
items: [
{ id: 'hero', type: 'hero', x: 1, y: 1, w: 7, h: 5 },
{ id: 'offers', type: 'offers', x: 8, y: 1, w: 5, h: 3 },
{ id: 'vehicles', type: 'vehicles', x: 1, y: 6, w: 7, h: 4 },
{ id: 'pricing', type: 'pricing', x: 8, y: 4, w: 5, h: 6 },
],
}
const SECTION_LIMITS: Record<PublicHomepageSectionType, { minW: number; minH: number; maxW: number; maxH: number }> = {
hero: { minW: 4, minH: 4, maxW: 12, maxH: 8 },
offers: { minW: 3, minH: 2, maxW: 12, maxH: 8 },
vehicles: { minW: 4, minH: 3, maxW: 12, maxH: 8 },
pricing: { minW: 4, minH: 4, maxW: 12, maxH: 9 },
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
export function clonePublicHomepageLayout(): PublicHomepageLayout {
return JSON.parse(JSON.stringify(defaultPublicHomepageLayout)) as PublicHomepageLayout
}
export function getPublicHomepageSectionLimits(type: PublicHomepageSectionType) {
return SECTION_LIMITS[type]
}
export function normalizePublicHomepageLayoutItem(item: PublicHomepageLayoutItem): PublicHomepageLayoutItem {
const limits = SECTION_LIMITS[item.type]
const w = clamp(Math.round(item.w), limits.minW, limits.maxW)
const h = clamp(Math.round(item.h), limits.minH, limits.maxH)
const x = clamp(Math.round(item.x), 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - w + 1)
const y = clamp(Math.round(item.y), 1, PUBLIC_HOMEPAGE_GRID_ROWS - h + 1)
return {
id: item.id,
type: item.type,
x,
y,
w,
h,
}
}
export function resolvePublicHomepageLayout(
layout?: Partial<PublicHomepageLayout> | null,
visibleTypes?: PublicHomepageSectionType[],
): PublicHomepageLayout {
const defaultsByType = new Map(defaultPublicHomepageLayout.items.map((item) => [item.type, item] as const))
const allowedTypes = new Set<PublicHomepageSectionType>(visibleTypes ?? (['hero', 'offers', 'vehicles', 'pricing'] as PublicHomepageSectionType[]))
const items = (layout?.items ?? [])
.filter((item): item is PublicHomepageLayoutItem => {
if (!item || typeof item !== 'object') return false
if (!('type' in item) || !allowedTypes.has(item.type as PublicHomepageSectionType)) return false
return ['id', 'x', 'y', 'w', 'h'].every((key) => key in item)
})
.map((item) => normalizePublicHomepageLayoutItem(item))
const seen = new Set(items.map((item) => item.type))
for (const type of allowedTypes) {
if (seen.has(type)) continue
const fallback = defaultsByType.get(type)
if (fallback) items.push(fallback)
}
return {
items: items.sort((a, b) => (a.y - b.y) || (a.x - b.x)),
}
}
@@ -0,0 +1,99 @@
export type PublicSitePageSections = {
home: {
hero: boolean
offers: boolean
vehicles: boolean
pricing: boolean
}
about: {
hero: boolean
highlights: boolean
details: boolean
}
offers: {
header: boolean
grid: boolean
}
pricing: {
hero: boolean
plans: boolean
payments: boolean
faq: boolean
}
vehicles: {
header: boolean
filters: boolean
grid: boolean
}
vehicleDetail: {
gallery: boolean
summary: boolean
}
blog: {
hero: boolean
posts: boolean
}
contact: {
content: boolean
}
booking: {
content: boolean
}
bookingConfirmation: {
hero: boolean
summary: boolean
actions: boolean
}
}
export const defaultPublicSitePageSections: PublicSitePageSections = {
home: {
hero: true,
offers: true,
vehicles: true,
pricing: true,
},
about: {
hero: true,
highlights: true,
details: true,
},
offers: {
header: true,
grid: true,
},
pricing: {
hero: true,
plans: true,
payments: true,
faq: true,
},
vehicles: {
header: true,
filters: true,
grid: true,
},
vehicleDetail: {
gallery: true,
summary: true,
},
blog: {
hero: true,
posts: true,
},
contact: {
content: true,
},
booking: {
content: true,
},
bookingConfirmation: {
hero: true,
summary: true,
actions: true,
},
}
export function clonePublicSitePageSections(): PublicSitePageSections {
return JSON.parse(JSON.stringify(defaultPublicSitePageSections)) as PublicSitePageSections
}