add homepage management
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
{
|
||||
"marketplaceHomepage": {
|
||||
"en": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
"title": "Unified publishing",
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
||||
},
|
||||
{
|
||||
"title": "Qualified discovery",
|
||||
"body": "Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast."
|
||||
},
|
||||
{
|
||||
"title": "Direct revenue path",
|
||||
"body": "Bookings move into the company’s own payment flow, so customer ownership and cash collection stay with the business."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Shared marketplace visibility"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "Direct company checkout"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "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": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Create your company workspace",
|
||||
"body": "Pick a plan, launch your 14-day trial, and verify the owner account."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publish vehicles and offers",
|
||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "Accept bookings on your own site",
|
||||
"body": "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": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
"body": "Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement."
|
||||
},
|
||||
{
|
||||
"title": "Revenus en direct",
|
||||
"body": "Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Visibilité marketplace partagée"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "Paiement direct entreprise"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "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": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Créez votre espace entreprise",
|
||||
"body": "Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publiez véhicules et offres",
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "Acceptez les réservations sur votre site",
|
||||
"body": "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": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "واجهة سوق أوضح وأقوى.",
|
||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.",
|
||||
"startTrial": "ابدأ التجربة المجانية",
|
||||
"exploreVehicles": "استكشف السيارات",
|
||||
"surfaceLabel": "واجهة السوق",
|
||||
"surfaceTitle": "مصممة لجمهورين في الوقت نفسه.",
|
||||
"surfaceBody": "المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.",
|
||||
"liveLabel": "شبكة نشطة",
|
||||
"trustedFleets": "أساطيل موثوقة",
|
||||
"brandedFlows": "مسارات حجز مخصصة",
|
||||
"multiTenant": "عمليات متعددة الشركات",
|
||||
"companyKicker": "تدفق المشغل",
|
||||
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
|
||||
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
|
||||
"renterKicker": "تجربة المستأجر",
|
||||
"renterTitle": "دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.",
|
||||
"renterBody": "السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "نشر موحد",
|
||||
"body": "صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||
},
|
||||
{
|
||||
"title": "اكتشاف مؤهل",
|
||||
"body": "العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة."
|
||||
},
|
||||
{
|
||||
"title": "مسار إيراد مباشر",
|
||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "ظهور مشترك في السوق"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "دفع مباشر للشركة"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "مدفوعات مملوكة للشركة"
|
||||
}
|
||||
],
|
||||
"featureLabel": "ما الذي يغطيه المنتج",
|
||||
"features": [
|
||||
"إدارة الأسطول مع رفع عدة صور",
|
||||
"عروض السوق والتحويل إلى مسار الحجز",
|
||||
"موقع حجز عام مخصص لكل شركة",
|
||||
"إدارة العملاء والتحليلات والفوترة"
|
||||
],
|
||||
"stepsTitle": "كيف تبدأ الشركات",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "أنشئ مساحة شركتك",
|
||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "انشر السيارات والعروض",
|
||||
"body": "ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "استقبل الحجوزات على موقعك",
|
||||
"body": "يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك."
|
||||
}
|
||||
],
|
||||
"stepLabel": "الخطوة",
|
||||
"readyKicker": "جاهز للانطلاق",
|
||||
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
|
||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.",
|
||||
"viewPricing": "عرض الأسعار",
|
||||
"createWorkspace": "إنشاء المساحة"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const getClientIpKey = (req: Parameters<typeof ipKeyGenerator>[0]) =>
|
||||
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
@@ -20,7 +20,7 @@ export const authLimiter = rateLimit({
|
||||
// Standard limiter for general authenticated API endpoints
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 120,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
@@ -35,7 +35,7 @@ export const apiLimiter = rateLimit({
|
||||
// Tight limiter for public marketplace and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
@@ -45,7 +45,7 @@ export const publicLimiter = rateLimit({
|
||||
// Tight limiter for admin endpoints
|
||||
export const adminLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { authenticator } from 'otplib'
|
||||
import qrcode from 'qrcode'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
|
||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../services/platformContentService'
|
||||
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -14,6 +16,244 @@ const permissionSchema = z.object({
|
||||
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
|
||||
})
|
||||
|
||||
const nullableString = z.union([z.string(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
|
||||
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
|
||||
const nullableDate = z.union([
|
||||
z.string().datetime(),
|
||||
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
z.null(),
|
||||
]).optional()
|
||||
|
||||
const homePageConfigSchema = z.object({
|
||||
heroTitle: nullableString,
|
||||
heroDescription: nullableString,
|
||||
viewOffersLabel: nullableString,
|
||||
viewPricingLabel: nullableString,
|
||||
contactCompanyLabel: nullableString,
|
||||
activeOffersTitle: nullableString,
|
||||
seeAllOffersLabel: nullableString,
|
||||
noActiveOffersLabel: nullableString,
|
||||
publishedVehiclesTitle: nullableString,
|
||||
viewVehicleLabel: nullableString,
|
||||
pricingEyebrow: nullableString,
|
||||
pricingTitle: nullableString,
|
||||
pricingDescription: nullableString,
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional()
|
||||
|
||||
const menuConfigSchema = z.object({
|
||||
aboutLabel: nullableString,
|
||||
vehiclesLabel: nullableString,
|
||||
offersLabel: nullableString,
|
||||
pricingLabel: nullableString,
|
||||
blogLabel: nullableString,
|
||||
contactLabel: nullableString,
|
||||
bookCarLabel: nullableString,
|
||||
siteNavigationLabel: nullableString,
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(),
|
||||
pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
highlights: z.boolean().optional(),
|
||||
details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({
|
||||
header: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(),
|
||||
faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(),
|
||||
filters: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({
|
||||
gallery: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
}).optional(),
|
||||
blog: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
posts: z.boolean().optional(),
|
||||
}).optional(),
|
||||
contact: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
booking: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional()
|
||||
|
||||
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({
|
||||
step: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum([
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
'audiences',
|
||||
'features',
|
||||
'steps',
|
||||
'closing',
|
||||
])
|
||||
|
||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
||||
sections: z.array(marketplaceSectionSchema).min(1),
|
||||
heroKicker: z.string().min(1),
|
||||
heroTitle: z.string().min(1),
|
||||
heroBody: z.string().min(1),
|
||||
startTrial: z.string().min(1),
|
||||
exploreVehicles: z.string().min(1),
|
||||
surfaceLabel: z.string().min(1),
|
||||
surfaceTitle: z.string().min(1),
|
||||
surfaceBody: z.string().min(1),
|
||||
liveLabel: z.string().min(1),
|
||||
trustedFleets: z.string().min(1),
|
||||
brandedFlows: z.string().min(1),
|
||||
multiTenant: z.string().min(1),
|
||||
companyKicker: z.string().min(1),
|
||||
companyTitle: z.string().min(1),
|
||||
companyBody: z.string().min(1),
|
||||
renterKicker: z.string().min(1),
|
||||
renterTitle: z.string().min(1),
|
||||
renterBody: z.string().min(1),
|
||||
pillars: z.array(marketplacePillarSchema).length(3),
|
||||
metrics: z.array(marketplaceMetricSchema).length(3),
|
||||
featureLabel: z.string().min(1),
|
||||
features: z.array(z.string().min(1)).min(1),
|
||||
stepsTitle: z.string().min(1),
|
||||
steps: z.array(marketplaceStepSchema).length(3),
|
||||
stepLabel: z.string().min(1),
|
||||
readyKicker: z.string().min(1),
|
||||
readyTitle: z.string().min(1),
|
||||
readyBody: z.string().min(1),
|
||||
viewPricing: z.string().min(1),
|
||||
createWorkspace: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceHomepageConfigSchema = z.object({
|
||||
en: marketplaceHomepageContentSchema,
|
||||
fr: marketplaceHomepageContentSchema,
|
||||
ar: marketplaceHomepageContentSchema,
|
||||
})
|
||||
|
||||
const adminCompanyUpdateSchema = z.object({
|
||||
company: z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
slug: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
}).optional(),
|
||||
subscription: z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
|
||||
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
trialStartAt: nullableDate,
|
||||
trialEndAt: nullableDate,
|
||||
currentPeriodStart: nullableDate,
|
||||
currentPeriodEnd: nullableDate,
|
||||
cancelledAt: nullableDate,
|
||||
cancelAtPeriodEnd: z.boolean().optional(),
|
||||
}).optional(),
|
||||
brand: z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: nullableString,
|
||||
subdomain: z.string().min(1).optional(),
|
||||
customDomain: nullableString,
|
||||
publicEmail: nullableEmail,
|
||||
publicPhone: nullableString,
|
||||
publicAddress: nullableString,
|
||||
publicCity: nullableString,
|
||||
publicCountry: nullableString,
|
||||
websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString,
|
||||
defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: homePageConfigSchema,
|
||||
menuConfig: menuConfigSchema,
|
||||
}).optional(),
|
||||
contractSettings: z.object({
|
||||
legalName: nullableString,
|
||||
registrationNumber: nullableString,
|
||||
taxId: nullableString,
|
||||
terms: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(),
|
||||
taxRate: z.number().nullable().optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
}).optional(),
|
||||
accountingSettings: z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
accountantEmail: nullableEmail,
|
||||
accountantName: nullableString,
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
function parseOptionalDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value)
|
||||
}
|
||||
|
||||
function toAuditJson<T>(value: T) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function signAdminToken(adminId: string) {
|
||||
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
|
||||
}
|
||||
@@ -97,11 +337,160 @@ router.get('/companies', requireAdminAuth, async (req, res, next) => {
|
||||
|
||||
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id }, include: { brand: true, subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } }, employees: true } })
|
||||
const company = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
res.json({ data: await getMarketplaceHomepageContent() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = z.object({ homepage: marketplaceHomepageConfigSchema }).parse(req.body)
|
||||
const saved = await saveMarketplaceHomepageContent(homepage)
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: req.admin.id,
|
||||
action: 'UPDATE',
|
||||
resource: 'MarketplaceHomepage',
|
||||
after: toAuditJson(saved),
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
},
|
||||
})
|
||||
res.json({ data: saved })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const body = adminCompanyUpdateSchema.parse(req.body)
|
||||
const before = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: true,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (body.company) {
|
||||
await tx.company.update({
|
||||
where: { id: req.params.id },
|
||||
data: body.company,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.subscription) {
|
||||
await tx.subscription.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: {
|
||||
...body.subscription,
|
||||
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
|
||||
},
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
plan: body.subscription.plan ?? 'STARTER',
|
||||
billingPeriod: body.subscription.billingPeriod ?? 'MONTHLY',
|
||||
status: body.subscription.status ?? 'TRIALING',
|
||||
currency: body.subscription.currency ?? 'MAD',
|
||||
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
|
||||
cancelAtPeriodEnd: body.subscription.cancelAtPeriodEnd ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (body.brand) {
|
||||
const currentCompany = await tx.company.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
await tx.brandSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.brand,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
displayName: body.brand.displayName ?? currentCompany.name,
|
||||
subdomain: body.brand.subdomain ?? currentCompany.slug,
|
||||
paymentMethodsEnabled: before.brand?.paymentMethodsEnabled ?? [],
|
||||
...body.brand,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (body.contractSettings) {
|
||||
await tx.contractSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.contractSettings,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
...body.contractSettings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (body.accountingSettings) {
|
||||
await tx.accountingSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.accountingSettings,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
...body.accountingSettings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: req.admin.id,
|
||||
action: 'UPDATE_COMPANY',
|
||||
resource: 'Company',
|
||||
resourceId: req.params.id,
|
||||
companyId: req.params.id,
|
||||
before: toAuditJson(before),
|
||||
after: toAuditJson(body),
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { status, reason } = z.object({ status: z.enum(['ACTIVE','SUSPENDED','CANCELLED']), reason: z.string().optional() }).parse(req.body)
|
||||
|
||||
@@ -37,6 +37,97 @@ const brandSchema = z.object({
|
||||
paypalEmail: z.string().email().optional(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
|
||||
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
|
||||
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
|
||||
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
|
||||
pricingTitle: z.union([z.string(), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string(), z.null()]).optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
menuConfig: z.object({
|
||||
aboutLabel: z.union([z.string(), z.null()]).optional(),
|
||||
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
|
||||
offersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
blogLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactLabel: z.union([z.string(), z.null()]).optional(),
|
||||
bookCarLabel: z.union([z.string(), z.null()]).optional(),
|
||||
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(),
|
||||
pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
highlights: z.boolean().optional(),
|
||||
details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({
|
||||
header: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(),
|
||||
faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(),
|
||||
filters: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({
|
||||
gallery: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
}).optional(),
|
||||
blog: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
posts: z.boolean().optional(),
|
||||
}).optional(),
|
||||
contact: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
booking: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const contractSettingsSchema = z.object({
|
||||
|
||||
@@ -7,9 +7,18 @@ import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
import { getMarketplaceHomepageContent } from '../services/platformContentService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/platform/homepage', async (_req, res, next) => {
|
||||
try {
|
||||
res.json({ data: await getMarketplaceHomepageContent() })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
|
||||
|
||||
function resolveContentPath() {
|
||||
const cwd = process.cwd()
|
||||
const appScoped = path.resolve(cwd, 'src/data/platform-content.json')
|
||||
const repoScoped = path.resolve(cwd, 'apps/api/src/data/platform-content.json')
|
||||
return cwd.includes('/apps/api') ? appScoped : repoScoped
|
||||
}
|
||||
|
||||
const platformContentPath = resolveContentPath()
|
||||
|
||||
type PlatformContentFile = {
|
||||
marketplaceHomepage?: MarketplaceHomepageConfig
|
||||
}
|
||||
|
||||
async function readPlatformContentFile(): Promise<PlatformContentFile> {
|
||||
try {
|
||||
const raw = await readFile(platformContentPath, 'utf8')
|
||||
return JSON.parse(raw) as PlatformContentFile
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writePlatformContentFile(content: PlatformContentFile) {
|
||||
await mkdir(path.dirname(platformContentPath), { recursive: true })
|
||||
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
|
||||
}
|
||||
|
||||
export async function getMarketplaceHomepageContent() {
|
||||
const content = await readPlatformContentFile()
|
||||
return content.marketplaceHomepage ?? cloneMarketplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig) {
|
||||
const content = await readPlatformContentFile()
|
||||
await writePlatformContentFile({
|
||||
...content,
|
||||
marketplaceHomepage: homepage,
|
||||
})
|
||||
return homepage
|
||||
}
|
||||
Reference in New Issue
Block a user