add homepage management
This commit is contained in:
@@ -426,6 +426,8 @@ model BrandSettings {
|
||||
paymentMethodsEnabled PaymentProvider[]
|
||||
isListedOnMarketplace Boolean @default(true)
|
||||
marketplaceRating Float?
|
||||
homePageConfig Json? @map("home_page_config")
|
||||
menuConfig Json? @map("menu_config")
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 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.'],
|
||||
].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 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.'],
|
||||
].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 l’espace',
|
||||
},
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user