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
+268
View File
@@ -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 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": [
{
"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 companys 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 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": [
{
"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 à lentreprise, pour garder la relation client et lencaissement."
}
],
"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 lespace"
},
"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": "إنشاء المساحة"
}
}
}
+4 -4
View File
@@ -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),
+390 -1
View File
@@ -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)
+91
View File
@@ -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({
+9
View File
@@ -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
}