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
@@ -0,0 +1,231 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
PUBLIC_HOMEPAGE_GRID_COLUMNS,
PUBLIC_HOMEPAGE_GRID_ROWS,
getPublicHomepageSectionLimits,
type PublicHomepageLayout,
type PublicHomepageLayoutItem,
type PublicHomepageSectionType,
} from '@rentaldrivego/types'
type Props = {
layout: PublicHomepageLayout
availableSections: PublicHomepageSectionType[]
onChange: (layout: PublicHomepageLayout) => void
onAddSection: (type: PublicHomepageSectionType) => void
onRemoveSection: (type: PublicHomepageSectionType) => void
}
type Interaction = {
itemId: string
mode: 'move' | 'resize'
startX: number
startY: number
origin: PublicHomepageLayoutItem
}
const ROW_HEIGHT = 52
const SECTION_META: Record<PublicHomepageSectionType, { label: string; tint: string; description: string }> = {
hero: { label: 'Hero', tint: 'from-sky-500 to-cyan-400', description: 'Headline, CTA buttons, and brand media.' },
offers: { label: 'Offers', tint: 'from-emerald-500 to-lime-400', description: 'Promotions and campaign cards.' },
vehicles: { label: 'Vehicles', tint: 'from-amber-500 to-orange-400', description: 'Published fleet grid.' },
pricing: { label: 'Pricing', tint: 'from-fuchsia-500 to-rose-400', description: 'Plans and pricing content.' },
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
export function HomepageLayoutEditor({ layout, availableSections, onChange, onAddSection, onRemoveSection }: Props) {
const canvasRef = useRef<HTMLDivElement | null>(null)
const [interaction, setInteraction] = useState<Interaction | null>(null)
useEffect(() => {
if (!interaction) return
function handlePointerMove(event: PointerEvent) {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS
const deltaCols = Math.round((event.clientX - interaction.startX) / colWidth)
const deltaRows = Math.round((event.clientY - interaction.startY) / ROW_HEIGHT)
const limits = getPublicHomepageSectionLimits(interaction.origin.type)
onChange({
items: layout.items.map((item) => {
if (item.id !== interaction.itemId) return item
if (interaction.mode === 'move') {
return {
...item,
x: clamp(interaction.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.w + 1),
y: clamp(interaction.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.h + 1),
}
}
const nextW = clamp(
interaction.origin.w + deltaCols,
limits.minW,
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.x + 1),
)
const nextH = clamp(
interaction.origin.h + deltaRows,
limits.minH,
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.y + 1),
)
return {
...item,
w: nextW,
h: nextH,
}
}),
})
}
function handlePointerUp() {
setInteraction(null)
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [interaction, layout.items, onChange])
return (
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4">
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h4 className="text-sm font-semibold text-zinc-100">Homepage layout</h4>
<p className="mt-1 text-xs text-zinc-500">Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.</p>
</div>
<div className="flex flex-wrap gap-2">
{availableSections.map((type) => (
<button
key={type}
type="button"
onClick={() => onAddSection(type)}
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300 transition hover:bg-emerald-500/20"
>
Add {SECTION_META[type].label}
</button>
))}
</div>
</div>
<div
ref={canvasRef}
className="relative mt-4 hidden overflow-hidden rounded-[1.5rem] border border-zinc-800 bg-zinc-950 lg:block"
style={{
height: ROW_HEIGHT * PUBLIC_HOMEPAGE_GRID_ROWS,
backgroundImage: `
linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)
`,
backgroundSize: `${100 / PUBLIC_HOMEPAGE_GRID_COLUMNS}% ${ROW_HEIGHT}px`,
}}
>
{layout.items.map((item) => {
const meta = SECTION_META[item.type]
return (
<div
key={item.id}
className="absolute overflow-hidden rounded-[1.25rem] border border-white/10 bg-zinc-900/95 shadow-xl shadow-black/30"
style={{
left: `${((item.x - 1) / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
top: `${(item.y - 1) * ROW_HEIGHT}px`,
width: `${(item.w / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
height: `${item.h * ROW_HEIGHT}px`,
}}
>
<button
type="button"
onPointerDown={(event) => {
event.preventDefault()
setInteraction({
itemId: item.id,
mode: 'move',
startX: event.clientX,
startY: event.clientY,
origin: item,
})
}}
className={`flex w-full cursor-grab items-start justify-between bg-gradient-to-r ${meta.tint} px-4 py-3 text-left active:cursor-grabbing`}
>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/75">Drag</p>
<p className="mt-1 text-sm font-black text-white">{meta.label}</p>
</div>
<span className="rounded-full bg-black/20 px-2 py-1 text-[11px] font-semibold text-white">
{item.w}x{item.h}
</span>
</button>
<div className="flex h-[calc(100%-60px)] flex-col justify-between p-4">
<p className="max-w-xs text-sm leading-6 text-zinc-300">{meta.description}</p>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-zinc-500">x{item.x} y{item.y}</span>
<button
type="button"
onClick={() => onRemoveSection(item.type)}
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20"
>
Remove
</button>
</div>
</div>
<button
type="button"
aria-label={`Resize ${meta.label}`}
onPointerDown={(event) => {
event.preventDefault()
setInteraction({
itemId: item.id,
mode: 'resize',
startX: event.clientX,
startY: event.clientY,
origin: item,
})
}}
className="absolute bottom-2 right-2 h-7 w-7 rounded-full border border-white/15 bg-white/10 text-white"
>
<span className="block rotate-45 text-sm">+</span>
</button>
</div>
)
})}
</div>
<div className="mt-4 grid gap-3 lg:hidden">
{layout.items.map((item) => {
const meta = SECTION_META[item.type]
return (
<div key={item.id} className="rounded-2xl border border-zinc-800 bg-zinc-900/70 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-zinc-100">{meta.label}</p>
<p className="mt-1 text-xs text-zinc-500">{meta.description}</p>
</div>
<button
type="button"
onClick={() => onRemoveSection(item.type)}
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300"
>
Remove
</button>
</div>
<p className="mt-3 text-xs text-zinc-500">Desktop drag canvas is available on larger screens.</p>
</div>
)
})}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -110,7 +110,7 @@ export default function AdminCompaniesPage() {
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
method: 'POST',
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ status }),
})
+1
View File
@@ -8,6 +8,7 @@ import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
const navLinks = [
{ href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/dashboard/companies', key: 'companies', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
@@ -0,0 +1,801 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
cloneMarketplaceHomepageContent,
resolveMarketplaceHomepageSections,
type MarketplaceHomepageConfig,
type MarketplaceHomepageContent,
type MarketplaceHomepageSectionType,
type MarketplaceLanguage,
} from '@rentaldrivego/types'
import { useAdminI18n } from '@/components/I18nProvider'
const API_BASE = '/api/v1'
interface Company {
id: string
name: string
slug: string
status: string
email: string
brand: {
displayName: string | null
} | null
}
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
TRIALING: 'text-sky-400 bg-sky-900/30',
SUSPENDED: 'text-red-400 bg-red-900/30',
PENDING: 'text-amber-400 bg-amber-900/30',
CANCELLED: 'text-zinc-400 bg-zinc-800',
}
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
})
return cloned
}
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
'hero',
'surface',
'pillars',
'audiences',
'features',
'steps',
'closing',
]
export default function AdminSiteConfigPage() {
const { language, dict } = useAdminI18n()
const copy = {
en: {
title: 'Site configuration',
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
homepageTitle: 'Main website homepage',
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
saveHomepage: 'Save homepage',
savingHomepage: 'Saving…',
homepageSaved: 'Marketplace homepage saved.',
search: 'Search by company or slug…',
loading: 'Loading…',
empty: 'No companies found',
company: 'Company',
slug: 'Slug',
status: 'Status',
actions: 'Actions',
configure: 'Configure site',
manageCompany: 'Open company',
companiesTitle: 'Company branded sites',
languageLabel: 'Language',
hero: 'Hero',
surface: 'Surface',
companySection: 'Operator card',
renterSection: 'Renter card',
valueSection: 'Value blocks',
featureSection: 'Feature list',
stepsSection: 'Launch steps',
closingSection: 'Closing call to action',
previewTitle: 'Live preview',
previewDescription: 'Draft changes appear here before you save them.',
homepageSections: 'Homepage sections',
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
addSection: 'Add',
removeSection: 'Remove',
expand: 'Expand',
collapse: 'Collapse',
},
fr: {
title: 'Configuration du site',
description: 'Modifiez ici la homepage principale de la marketplace, ou ouvrez une entreprise pour gérer sa homepage publique et son menu.',
homepageTitle: 'Homepage du site principal',
homepageDescription: 'Cette section contrôle la homepage marketplace affichée sur le site principal RentalDriveGo.',
saveHomepage: 'Enregistrer la homepage',
savingHomepage: 'Enregistrement…',
homepageSaved: 'Homepage marketplace enregistrée.',
search: 'Rechercher par entreprise ou slug…',
loading: 'Chargement…',
empty: 'Aucune entreprise trouvée',
company: 'Entreprise',
slug: 'Slug',
status: 'Statut',
actions: 'Actions',
configure: 'Configurer le site',
manageCompany: 'Ouvrir lentreprise',
companiesTitle: 'Sites de marque des entreprises',
languageLabel: 'Langue',
hero: 'Hero',
surface: 'Bloc marketplace',
companySection: 'Bloc opérateur',
renterSection: 'Bloc client',
valueSection: 'Blocs de valeur',
featureSection: 'Liste des fonctionnalités',
stepsSection: 'Étapes de lancement',
closingSection: 'Appel à laction final',
previewTitle: 'Aperçu en direct',
previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.',
homepageSections: 'Sections de la homepage',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la homepage marketplace principale.',
addSection: 'Ajouter',
removeSection: 'Retirer',
expand: 'Ouvrir',
collapse: 'Réduire',
},
ar: {
title: 'إعدادات الموقع',
description: 'عدّل الصفحة الرئيسية للموقع الرئيسي هنا، أو افتح شركة لإدارة صفحتها العامة وقائمة التنقل الخاصة بها.',
homepageTitle: 'الصفحة الرئيسية للموقع الرئيسي',
homepageDescription: 'هذا القسم يتحكم في الصفحة الرئيسية للمنصة على موقع RentalDriveGo الرئيسي.',
saveHomepage: 'حفظ الصفحة الرئيسية',
savingHomepage: 'جارٍ الحفظ…',
homepageSaved: 'تم حفظ الصفحة الرئيسية للمنصة.',
search: 'ابحث باسم الشركة أو slug…',
loading: 'جارٍ التحميل…',
empty: 'لم يتم العثور على شركات',
company: 'الشركة',
slug: 'Slug',
status: 'الحالة',
actions: 'الإجراءات',
configure: 'إعداد الموقع',
manageCompany: 'فتح الشركة',
companiesTitle: 'المواقع العامة للشركات',
languageLabel: 'اللغة',
hero: 'البطاقة الرئيسية',
surface: 'قسم المنصة',
companySection: 'بطاقة المشغل',
renterSection: 'بطاقة المستأجر',
valueSection: 'عناصر القيمة',
featureSection: 'قائمة الميزات',
stepsSection: 'خطوات الإطلاق',
closingSection: 'الدعوة الختامية',
previewTitle: 'معاينة مباشرة',
previewDescription: 'تظهر التغييرات هنا قبل الحفظ.',
homepageSections: 'أقسام الصفحة الرئيسية',
homepageSectionsDescription: 'أضف أو احذف كتل الصفحة الرئيسية للمنصة.',
addSection: 'إضافة',
removeSection: 'إزالة',
expand: 'توسيع',
collapse: 'طي',
},
}[language]
const [companies, setCompanies] = useState<Company[]>([])
const [filtered, setFiltered] = useState<Company[]>([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
const [homepageSaving, setHomepageSaving] = useState(false)
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
const [homepageExpanded, setHomepageExpanded] = useState(false)
useEffect(() => {
setHomepageLanguage(language)
}, [language])
useEffect(() => {
async function fetchData() {
const token = localStorage.getItem('admin_token')
try {
const [companiesRes, homepageRes] = await Promise.all([
fetch(`${API_BASE}/admin/companies`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
}),
fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
}),
])
const companiesJson = await companiesRes.json()
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
setCompanies(companiesJson.data ?? [])
setFiltered(companiesJson.data ?? [])
const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) {
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
useEffect(() => {
const q = search.toLowerCase()
setFiltered(
companies.filter((company) =>
company.name.toLowerCase().includes(q)
|| company.slug.toLowerCase().includes(q)
|| company.email.toLowerCase().includes(q),
),
)
}, [search, companies])
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
setHomepage((current) => ({
...current,
[homepageLanguage]: {
...current[homepageLanguage],
...patch,
},
}))
}
function updateMetric(index: number, label: string) {
const metrics = homepage[homepageLanguage].metrics.map((metric, metricIndex) => (
metricIndex === index ? { ...metric, label } : metric
))
updateHomepageContent({ metrics })
}
function updatePillar(index: number, patch: { title?: string; body?: string }) {
const pillars = homepage[homepageLanguage].pillars.map((pillar, pillarIndex) => (
pillarIndex === index ? { ...pillar, ...patch } : pillar
))
updateHomepageContent({ pillars })
}
function updateStep(index: number, patch: { title?: string; body?: string }) {
const steps = homepage[homepageLanguage].steps.map((step, stepIndex) => (
stepIndex === index ? { ...step, ...patch } : step
))
updateHomepageContent({ steps })
}
function getActiveSections() {
return resolveMarketplaceHomepageSections(activeContent.sections)
}
function addHomepageSection(section: MarketplaceHomepageSectionType) {
const sections = getActiveSections()
if (sections.includes(section)) return
updateHomepageContent({ sections: [...sections, section] })
}
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
const sections = getActiveSections().filter((item) => item !== section)
if (sections.length === 0) return
updateHomepageContent({ sections })
}
async function saveHomepage() {
setHomepageSaving(true)
setHomepageMessage(null)
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ homepage }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
setHomepageMessage(copy.homepageSaved)
} catch (err: any) {
setError(err.message)
} finally {
setHomepageSaving(false)
}
}
const activeContent = homepage[homepageLanguage]
const activeSections = getActiveSections()
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
hero: copy.hero,
surface: copy.surface,
pillars: copy.valueSection,
audiences: `${copy.companySection} / ${copy.renterSection}`,
features: copy.featureSection,
steps: copy.stepsSection,
closing: copy.closingSection,
}
return (
<div className="shell space-y-6 py-8">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
<p className="mt-2 max-w-3xl text-sm text-zinc-500">{copy.description}</p>
</div>
<input
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 xl:w-72"
placeholder={copy.search}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
{error ? <div className="panel p-4 text-sm text-red-400">{error}</div> : null}
<section className="panel p-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-100">{copy.homepageTitle}</h2>
<p className="mt-1 text-sm text-zinc-500">{copy.homepageDescription}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
<button
key={value}
type="button"
onClick={() => setHomepageLanguage(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
homepageLanguage === value ? 'bg-emerald-500 text-white' : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
}`}
>
{value.toUpperCase()}
</button>
))}
</div>
<button
type="button"
onClick={() => setHomepageExpanded((current) => !current)}
className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200 transition hover:border-zinc-600 hover:bg-zinc-800"
>
{homepageExpanded ? copy.collapse : copy.expand}
</button>
</div>
</div>
{homepageExpanded ? (
<>
{homepageMessage ? <div className="mt-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">{homepageMessage}</div> : null}
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/60 p-4">
<h3 className="text-sm font-semibold text-zinc-100">{copy.homepageSections}</h3>
<p className="mt-1 text-xs text-zinc-500">{copy.homepageSectionsDescription}</p>
<div className="mt-4 flex flex-wrap gap-2">
{activeSections.map((section) => (
<div key={section} className="inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200">
<span>{sectionLabels[section]}</span>
<button
type="button"
onClick={() => removeHomepageSection(section)}
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-rose-300"
>
{copy.removeSection}
</button>
</div>
))}
</div>
{availableSections.length ? (
<div className="mt-4 flex flex-wrap gap-2">
{availableSections.map((section) => (
<button
key={section}
type="button"
onClick={() => addHomepageSection(section)}
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-emerald-500/20"
>
{copy.addSection} {sectionLabels[section]}
</button>
))}
</div>
) : null}
</div>
<div className="mt-6 rounded-[1.75rem] border border-zinc-800 bg-zinc-950/60 p-4">
<div className="flex items-center justify-between gap-4 border-b border-zinc-800 pb-4">
<div>
<h3 className="text-sm font-semibold text-zinc-100">{copy.previewTitle}</h3>
<p className="mt-1 text-xs text-zinc-500">{copy.previewDescription}</p>
</div>
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-300">
{homepageLanguage.toUpperCase()}
</span>
</div>
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-stone-200 bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] shadow-2xl shadow-black/20">
<div className="border-b border-stone-200 bg-white/90 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700">{activeContent.heroKicker}</p>
<p className="mt-2 text-sm text-stone-500">rentaldrivego.com</p>
</div>
<div className="flex flex-wrap gap-2 text-xs font-semibold text-stone-600">
{activeContent.metrics.map((metric) => (
<span key={metric.value} className="rounded-full border border-stone-300 bg-white px-3 py-1.5">
{metric.label}
</span>
))}
</div>
</div>
</div>
<div className="space-y-10 px-5 py-6 lg:px-8">
{activeSections.includes('hero') ? <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<div>
<h4 className="text-4xl font-black tracking-[-0.04em] text-stone-950">{activeContent.heroTitle}</h4>
<p className="mt-5 max-w-2xl text-base leading-8 text-stone-600">{activeContent.heroBody}</p>
<div className="mt-8 flex flex-wrap gap-3">
<span className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
<span className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-700">{activeContent.exploreVehicles}</span>
</div>
</div>
{activeSections.includes('surface') ? (
<div className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
{activeContent.surfaceLabel}
</span>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
{activeContent.liveLabel}
</span>
</div>
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
<div className="mt-6 space-y-3">
{[activeContent.trustedFleets, activeContent.brandedFlows, activeContent.multiTenant].map((item, index) => (
<div key={`${item}-${index}`} className="rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-3 text-sm font-semibold">
{item}
</div>
))}
</div>
</div>
) : null}
</section> : null}
{activeSections.includes('surface') && !activeSections.includes('hero') ? (
<section className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
{activeContent.surfaceLabel}
</span>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
{activeContent.liveLabel}
</span>
</div>
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
</section>
) : null}
{activeSections.includes('pillars') ? <section className="grid gap-4 lg:grid-cols-3">
{activeContent.pillars.map((pillar, index) => (
<article
key={`${pillar.title}-${index}`}
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-stone-900 bg-stone-950 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
>
<p className={`text-xs font-bold uppercase tracking-[0.24em] ${index === 1 ? 'text-stone-300' : 'text-stone-400'}`}>0{index + 1}</p>
<h5 className="mt-3 text-xl font-black">{pillar.title}</h5>
<p className={`mt-3 text-sm leading-7 ${index === 1 ? 'text-stone-200' : 'text-stone-600'}`}>{pillar.body}</p>
</article>
))}
</section> : null}
{activeSections.includes('audiences') ? <section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.companyKicker}</p>
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.companyTitle}</h5>
<p className="mt-3 text-sm leading-7 text-stone-600">{activeContent.companyBody}</p>
</article>
<article className="rounded-[1.5rem] border border-stone-200 bg-[#f7efe2] p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.renterKicker}</p>
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.renterTitle}</h5>
<p className="mt-3 text-sm leading-7 text-stone-700">{activeContent.renterBody}</p>
</article>
</section> : null}
{(activeSections.includes('features') || activeSections.includes('steps')) ? <section className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
{activeSections.includes('features') ? (
<article className="rounded-[1.5rem] border border-stone-200 bg-[#fffdf8] p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.featureLabel}</p>
<div className="mt-5 space-y-3">
{activeContent.features.map((feature, index) => (
<div key={`${feature}-${index}`} className="flex items-start gap-3 rounded-[1rem] border border-stone-200 bg-white px-4 py-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-stone-950 text-xs font-bold text-white">
{index + 1}
</span>
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
</div>
))}
</div>
</article>
) : <div />}
{activeSections.includes('steps') ? (
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.readyKicker}</p>
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.stepsTitle}</h5>
<div className="mt-5 space-y-3">
{activeContent.steps.map((step) => (
<div key={step.step} className="rounded-[1rem] border border-stone-200 bg-stone-50 p-4">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.stepLabel} {step.step}</p>
<h6 className="mt-2 text-lg font-black text-stone-950">{step.title}</h6>
<p className="mt-2 text-sm leading-7 text-stone-600">{step.body}</p>
</div>
))}
</div>
</article>
) : <div />}
</section> : null}
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-stone-950 px-6 py-7 text-white">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-amber-300">{activeContent.readyKicker}</p>
<h5 className="mt-4 max-w-3xl text-3xl font-black tracking-[-0.04em]">{activeContent.readyTitle}</h5>
<p className="mt-4 max-w-2xl text-sm leading-8 text-stone-300">{activeContent.readyBody}</p>
<div className="mt-6 flex flex-wrap gap-3">
<span className="rounded-full border border-white/20 px-5 py-3 text-sm font-semibold">{activeContent.viewPricing}</span>
<span className="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-stone-950">{activeContent.createWorkspace}</span>
</div>
</section> : null}
</div>
</div>
</div>
<div className="mt-6 space-y-8">
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Brand kicker</span>
<input className={INPUT_CLASS} value={activeContent.heroKicker} onChange={(event) => updateHomepageContent({ heroKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Primary CTA</span>
<input className={INPUT_CLASS} value={activeContent.startTrial} onChange={(event) => updateHomepageContent({ startTrial: event.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.hero} title</span>
<input className={INPUT_CLASS} value={activeContent.heroTitle} onChange={(event) => updateHomepageContent({ heroTitle: event.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.hero} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.heroBody} onChange={(event) => updateHomepageContent({ heroBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Secondary CTA</span>
<input className={INPUT_CLASS} value={activeContent.exploreVehicles} onChange={(event) => updateHomepageContent({ exploreVehicles: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.surface} badge</span>
<input className={INPUT_CLASS} value={activeContent.surfaceLabel} onChange={(event) => updateHomepageContent({ surfaceLabel: event.target.value })} />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.surface} title</span>
<input className={INPUT_CLASS} value={activeContent.surfaceTitle} onChange={(event) => updateHomepageContent({ surfaceTitle: event.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.surface} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.surfaceBody} onChange={(event) => updateHomepageContent({ surfaceBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Live label</span>
<input className={INPUT_CLASS} value={activeContent.liveLabel} onChange={(event) => updateHomepageContent({ liveLabel: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Trusted fleets</span>
<input className={INPUT_CLASS} value={activeContent.trustedFleets} onChange={(event) => updateHomepageContent({ trustedFleets: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Branded flows</span>
<input className={INPUT_CLASS} value={activeContent.brandedFlows} onChange={(event) => updateHomepageContent({ brandedFlows: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Multi-tenant</span>
<input className={INPUT_CLASS} value={activeContent.multiTenant} onChange={(event) => updateHomepageContent({ multiTenant: event.target.value })} />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>{copy.companySection} kicker</span>
<input className={INPUT_CLASS} value={activeContent.companyKicker} onChange={(event) => updateHomepageContent({ companyKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.renterSection} kicker</span>
<input className={INPUT_CLASS} value={activeContent.renterKicker} onChange={(event) => updateHomepageContent({ renterKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.companySection} title</span>
<input className={INPUT_CLASS} value={activeContent.companyTitle} onChange={(event) => updateHomepageContent({ companyTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.renterSection} title</span>
<input className={INPUT_CLASS} value={activeContent.renterTitle} onChange={(event) => updateHomepageContent({ renterTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.companySection} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.companyBody} onChange={(event) => updateHomepageContent({ companyBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.renterSection} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.renterBody} onChange={(event) => updateHomepageContent({ renterBody: event.target.value })} />
</label>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.valueSection}</h3>
<div className="grid gap-4">
{activeContent.metrics.map((metric, index) => (
<label key={metric.value}>
<span className={LABEL_CLASS}>Metric {metric.value}</span>
<input className={INPUT_CLASS} value={metric.label} onChange={(event) => updateMetric(index, event.target.value)} />
</label>
))}
{activeContent.pillars.map((pillar, index) => (
<div key={`${pillar.title}-${index}`} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
<label>
<span className={LABEL_CLASS}>Pillar {index + 1} title</span>
<input className={INPUT_CLASS} value={pillar.title} onChange={(event) => updatePillar(index, { title: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Pillar {index + 1} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={pillar.body} onChange={(event) => updatePillar(index, { body: event.target.value })} />
</label>
</div>
))}
</div>
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.featureSection}</h3>
<label>
<span className={LABEL_CLASS}>Feature section label</span>
<input className={INPUT_CLASS} value={activeContent.featureLabel} onChange={(event) => updateHomepageContent({ featureLabel: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Features, one per line</span>
<textarea
className={`${INPUT_CLASS} min-h-40`}
value={activeContent.features.join('\n')}
onChange={(event) => updateHomepageContent({ features: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })}
/>
</label>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.stepsSection}</h3>
<label>
<span className={LABEL_CLASS}>Steps title</span>
<input className={INPUT_CLASS} value={activeContent.stepsTitle} onChange={(event) => updateHomepageContent({ stepsTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Step label</span>
<input className={INPUT_CLASS} value={activeContent.stepLabel} onChange={(event) => updateHomepageContent({ stepLabel: event.target.value })} />
</label>
{activeContent.steps.map((step, index) => (
<div key={step.step} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">Step {step.step}</p>
<label>
<span className={LABEL_CLASS}>Title</span>
<input className={INPUT_CLASS} value={step.title} onChange={(event) => updateStep(index, { title: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={step.body} onChange={(event) => updateStep(index, { body: event.target.value })} />
</label>
</div>
))}
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.closingSection}</h3>
<label>
<span className={LABEL_CLASS}>Closing kicker</span>
<input className={INPUT_CLASS} value={activeContent.readyKicker} onChange={(event) => updateHomepageContent({ readyKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Closing title</span>
<input className={INPUT_CLASS} value={activeContent.readyTitle} onChange={(event) => updateHomepageContent({ readyTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Closing body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.readyBody} onChange={(event) => updateHomepageContent({ readyBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Pricing CTA</span>
<input className={INPUT_CLASS} value={activeContent.viewPricing} onChange={(event) => updateHomepageContent({ viewPricing: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Workspace CTA</span>
<input className={INPUT_CLASS} value={activeContent.createWorkspace} onChange={(event) => updateHomepageContent({ createWorkspace: event.target.value })} />
</label>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={saveHomepage}
disabled={homepageSaving}
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{homepageSaving ? copy.savingHomepage : copy.saveHomepage}
</button>
</div>
</>
) : null}
</section>
<section className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4">
<h2 className="text-base font-semibold text-zinc-100">{copy.companiesTitle}</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.company}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.slug}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.status}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td>
</tr>
) : filtered.map((company) => (
<tr key={company.id} className="transition-colors hover:bg-zinc-800/30">
<td className="px-6 py-4">
<p className="font-medium text-zinc-100">{company.brand?.displayName ?? company.name}</p>
<p className="text-xs text-zinc-500">{company.email}</p>
</td>
<td className="px-6 py-4 font-mono text-xs text-zinc-400">{company.slug}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[company.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
{company.status}
</span>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<Link
href={`/dashboard/companies/${company.id}#site-config`}
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
>
{copy.configure}
</Link>
<Link
href={`/dashboard/companies/${company.id}`}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 transition-colors hover:bg-zinc-700"
>
{copy.manageCompany}
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)
}
@@ -19,6 +19,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
nav: {
overview: 'Overview',
companies: 'Companies',
siteConfig: 'Site Config',
renters: 'Renters',
auditLogs: 'Audit Logs',
adminUsers: 'Admin Users',
@@ -34,6 +35,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
nav: {
overview: 'Vue densemble',
companies: 'Entreprises',
siteConfig: 'Config site',
renters: 'Locataires',
auditLogs: 'Journaux daudit',
adminUsers: 'Utilisateurs admin',
@@ -49,6 +51,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
nav: {
overview: 'نظرة عامة',
companies: 'الشركات',
siteConfig: 'إعدادات الموقع',
renters: 'المستأجرون',
auditLogs: 'سجلات التدقيق',
adminUsers: 'مستخدمو الإدارة',
+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
}
@@ -4,7 +4,6 @@ import Link from 'next/link'
import { SignIn } from '@clerk/nextjs'
import { clerkFrontendEnabled } from '@/lib/clerk'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
export default function SignInPage() {
@@ -12,14 +11,7 @@ export default function SignInPage() {
const dict = {
en: {
workspace: 'Company workspace',
body: 'Sign in to manage fleet operations, reservations, billing, team access, and performance reporting from one workspace.',
features: [
['Reservations', 'Track bookings, handoffs, availability, and customer activity without leaving the dashboard.'],
['Billing', 'Review plan status, trial timing, payment setup, and subscription changes in one place.'],
['Team access', 'Owners and staff keep separate access with role-based controls and invited employee accounts.'],
['Analytics', 'Monitor fleet usage, revenue movement, and offer performance from the operations view.'],
],
access: 'Workspace Access',
access: 'Workspace access',
signIn: 'Sign in',
useAccount: 'Use your owner or employee account to access the company workspace.',
inactive: 'Enter your workspace credentials to continue.',
@@ -30,13 +22,6 @@ export default function SignInPage() {
},
fr: {
workspace: 'Espace entreprise',
body: 'Connectez-vous pour gérer la flotte, les réservations, la facturation, laccès équipe et le reporting depuis un seul espace.',
features: [
['Réservations', 'Suivez les réservations, remises, disponibilités et activité client sans quitter le dashboard.'],
['Facturation', 'Consultez le plan, la période dessai, la configuration des paiements et les changements dabonnement.'],
['Accès équipe', 'Les propriétaires et collaborateurs ont des accès séparés avec rôles et invitations.'],
['Analytique', 'Surveillez lusage de la flotte, les revenus et la performance des offres.'],
],
access: 'Accès workspace',
signIn: 'Connexion',
useAccount: 'Utilisez votre compte propriétaire ou employé pour accéder à lespace entreprise.',
@@ -48,13 +33,6 @@ export default function SignInPage() {
},
ar: {
workspace: 'مساحة الشركة',
body: 'سجّل الدخول لإدارة الأسطول والحجوزات والفوترة ووصول الفريق والتقارير من مساحة واحدة.',
features: [
['الحجوزات', 'تابع الحجوزات والتسليمات والتوفر ونشاط العملاء من داخل اللوحة.'],
['الفوترة', 'راجع الخطة وفترة التجربة وإعدادات الدفع وتغييرات الاشتراك في مكان واحد.'],
['وصول الفريق', 'يحصل المالكون والموظفون على وصول منفصل مع أدوار ودعوات منظمة.'],
['التحليلات', 'راقب استخدام الأسطول وحركة الإيرادات وأداء العروض.'],
],
access: 'الوصول إلى المساحة',
signIn: 'تسجيل الدخول',
useAccount: 'استخدم حساب المالك أو الموظف للوصول إلى مساحة الشركة.',
@@ -67,28 +45,19 @@ export default function SignInPage() {
}[language]
return (
<PublicShell>
<main className="px-4 py-12">
<div className="mx-auto flex min-h-[calc(100vh-11rem)] max-w-6xl items-center">
<div className="grid w-full gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<section className="space-y-8">
<div>
<Link href={marketplaceUrl} className="inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</Link>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.workspace}</h1>
<p className="mt-4 max-w-xl text-base leading-7 text-slate-600">{dict.body}</p>
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
RentalDriveGo
</Link>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.workspace}</h1>
<p className="mt-2 text-sm text-slate-500">{dict.useAccount}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{dict.features.map(([title, body]) => (
<FeatureCard key={title} title={title} body={body} />
))}
</div>
</section>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{dict.access}</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.signIn}</h2>
<p className="mt-3 text-sm leading-6 text-slate-500">{dict.useAccount}</p>
<div className="mt-8">
{clerkFrontendEnabled ? <ConfiguredSignIn /> : <FallbackSignInCard dict={dict} />}
@@ -102,9 +71,7 @@ export default function SignInPage() {
</div>
</section>
</div>
</div>
</main>
</PublicShell>
)
}
@@ -142,12 +109,3 @@ function FallbackSignInCard({ dict }: { dict: { inactive: string; email: string;
</div>
)
}
function FeatureCard({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-slate-900">{title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-600">{body}</p>
</div>
)
}
+56 -177
View File
@@ -1,184 +1,56 @@
'use client'
import { useEffect, useState } from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import {
cloneMarketplaceHomepageContent,
resolveMarketplaceHomepageSections,
type MarketplaceHomepageConfig,
type MarketplaceHomepageSectionType,
} from '@rentaldrivego/types'
const copy = {
en: {
heroKicker: 'RentalDriveGo',
heroTitle: 'Marketplace discovery with a sharper front door.',
heroBody:
'Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the companys own branded checkout.',
startTrial: 'Start free trial',
exploreVehicles: 'Explore vehicles',
surfaceLabel: 'Marketplace surface',
surfaceTitle: 'Designed for two audiences at once.',
surfaceBody:
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
liveLabel: 'Live network',
trustedFleets: 'Trusted fleets',
brandedFlows: 'Branded booking flows',
multiTenant: 'Multi-tenant operations',
companyKicker: 'Operator workflow',
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.',
companyBody:
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
renterKicker: 'Renter experience',
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
renterBody:
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.',
pillars: [
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
['Direct revenue path', 'Bookings move into the companys own payment flow, so customer ownership and cash collection stay with the business.'],
],
metrics: [
['01', 'Shared marketplace visibility'],
['02', 'Direct company checkout'],
['03', 'Company-owned payments'],
],
featureLabel: 'What the product covers',
features: [
'Fleet management with multi-photo uploads',
'Marketplace offers and redirect booking flow',
'Branded public booking site per company',
'Customer CRM, analytics, and billing controls',
],
stepsTitle: 'How companies launch',
steps: [
['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'],
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
],
stepLabel: 'Step',
readyKicker: 'Ready to launch',
readyTitle: 'A marketplace homepage should sell the system in seconds.',
readyBody:
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.',
viewPricing: 'View pricing',
createWorkspace: 'Create workspace',
},
fr: {
heroKicker: 'RentalDriveGo',
heroTitle: 'Une vitrine marketplace plus nette et plus forte.',
heroBody:
'Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.',
startTrial: 'Commencer lessai gratuit',
exploreVehicles: 'Explorer les véhicules',
surfaceLabel: 'Surface marketplace',
surfaceTitle: 'Pensée pour deux audiences à la fois.',
surfaceBody:
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
liveLabel: 'Réseau actif',
trustedFleets: 'Flottes fiables',
brandedFlows: 'Parcours de marque',
multiTenant: 'Opérations multi-tenant',
companyKicker: 'Flux opérateur',
companyTitle: 'Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.',
companyBody:
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
renterKicker: 'Expérience client',
renterTitle: 'Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.',
renterBody:
'La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
pillars: [
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'],
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise, pour garder la relation client et lencaissement.'],
],
metrics: [
['01', 'Visibilité marketplace partagée'],
['02', 'Paiement direct entreprise'],
['03', 'Paiements détenus par la société'],
],
featureLabel: 'Ce que couvre le produit',
features: [
'Gestion de flotte avec téléversement multi-photos',
'Offres marketplace et redirection vers la réservation',
'Site public de réservation par entreprise',
'CRM client, analytics et contrôle de facturation',
],
stepsTitle: 'Comment les entreprises démarrent',
steps: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'],
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'],
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'],
],
stepLabel: 'Étape',
readyKicker: 'Prêt à démarrer',
readyTitle: 'Une homepage marketplace doit vendre le système en quelques secondes.',
readyBody:
'Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.',
viewPricing: 'Voir les tarifs',
createWorkspace: 'Créer lespace',
},
ar: {
heroKicker: 'RentalDriveGo',
heroTitle: 'واجهة سوق أوضح وأقوى.',
heroBody:
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.',
startTrial: 'ابدأ التجربة المجانية',
exploreVehicles: 'استكشف السيارات',
surfaceLabel: 'واجهة السوق',
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
surfaceBody:
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
liveLabel: 'شبكة نشطة',
trustedFleets: 'أساطيل موثوقة',
brandedFlows: 'مسارات حجز مخصصة',
multiTenant: 'عمليات متعددة الشركات',
companyKicker: 'تدفق المشغل',
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
companyBody:
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
renterKicker: 'تجربة المستأجر',
renterTitle: 'دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.',
renterBody:
'السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.',
pillars: [
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'],
],
metrics: [
['01', 'ظهور مشترك في السوق'],
['02', 'دفع مباشر للشركة'],
['03', 'مدفوعات مملوكة للشركة'],
],
featureLabel: 'ما الذي يغطيه المنتج',
features: [
'إدارة الأسطول مع رفع عدة صور',
'عروض السوق والتحويل إلى مسار الحجز',
'موقع حجز عام مخصص لكل شركة',
'إدارة العملاء والتحليلات والفوترة',
],
stepsTitle: 'كيف تبدأ الشركات',
steps: [
['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
],
stepLabel: 'الخطوة',
readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyBody:
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.',
viewPricing: 'عرض الأسعار',
createWorkspace: 'إنشاء المساحة',
},
} as const
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
export default function HomeContent() {
const { language } = useMarketplacePreferences()
const dict = copy[language]
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
const dict = content[language]
const sections = resolveMarketplaceHomepageSections(dict.sections)
const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY&currency=MAD`
useEffect(() => {
let cancelled = false
async function loadHomepageContent() {
try {
const res = await fetch(`${API_BASE}/site/platform/homepage`, { cache: 'no-store' })
const json = await res.json().catch(() => null)
if (!res.ok || !json?.data || cancelled) return
setContent(json.data as MarketplaceHomepageConfig)
} catch {
// Fall back to the shared defaults when the API is unavailable.
}
}
loadHomepageContent()
return () => {
cancelled = true
}
}, [])
function hasSection(section: MarketplaceHomepageSectionType) {
return sections.includes(section)
}
return (
<main className="min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] dark:bg-[linear-gradient(180deg,#14110f_0%,#17120d_35%,#0c0a09_100%)]">
<section className="relative">
<div className="absolute inset-x-0 top-0 h-[38rem] bg-[radial-gradient(circle_at_top_left,rgba(217,119,6,0.24),transparent_34%),radial-gradient(circle_at_top_right,rgba(15,118,110,0.18),transparent_26%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_34%),radial-gradient(circle_at_top_right,rgba(45,212,191,0.14),transparent_26%)]" />
<div className="shell relative py-10 sm:py-14 lg:py-20">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]">
<div className={`grid gap-8 ${hasSection('hero') && hasSection('surface') ? 'lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]' : ''}`}>
{hasSection('hero') ? (
<div className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-stone-800 dark:bg-stone-900/70">
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700 dark:text-amber-300">{dict.heroKicker}</p>
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-stone-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
@@ -204,7 +76,7 @@ export default function HomeContent() {
</div>
<div className="mt-10 grid gap-3 sm:grid-cols-3">
{dict.metrics.map(([value, label]) => (
{dict.metrics.map(({ value, label }) => (
<div key={value} className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 p-4 dark:border-stone-800 dark:bg-stone-950/50">
<p className="text-xs font-bold tracking-[0.28em] text-stone-400 dark:text-stone-500">{value}</p>
<p className="mt-3 text-sm font-semibold leading-6 text-stone-900 dark:text-stone-100">{label}</p>
@@ -212,7 +84,9 @@ export default function HomeContent() {
))}
</div>
</div>
) : null}
{hasSection('surface') ? (
<div className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-stone-950 p-6 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] dark:border-stone-700">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(245,158,11,0.34),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(20,184,166,0.25),transparent_32%)]" />
<div className="relative">
@@ -247,9 +121,10 @@ export default function HomeContent() {
</div>
</div>
</div>
) : null}
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
{hasSection('audiences') ? <div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<article className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 backdrop-blur dark:border-stone-800 dark:bg-stone-900/60">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.companyKicker}</p>
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
@@ -260,13 +135,13 @@ export default function HomeContent() {
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-stone-950 dark:text-stone-50 sm:text-3xl">{dict.renterTitle}</h2>
<p className="mt-4 text-sm leading-7 text-stone-700 dark:text-stone-300">{dict.renterBody}</p>
</article>
</div>
</div> : null}
</div>
</section>
<section className="shell pb-10">
{hasSection('pillars') ? <section className="shell pb-10">
<div className="grid gap-4 lg:grid-cols-3">
{dict.pillars.map(([title, body], index) => (
{dict.pillars.map(({ title, body }, index) => (
<article
key={title}
className={`rounded-[2rem] border p-7 shadow-sm ${
@@ -293,10 +168,11 @@ export default function HomeContent() {
</article>
))}
</div>
</section>
</section> : null}
<section className="shell py-10">
{(hasSection('features') || hasSection('steps')) ? <section className="shell py-10">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
{hasSection('features') ? (
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#fffdf8_0%,#f1ebe1_100%)] p-8 dark:border-stone-800 dark:bg-[linear-gradient(160deg,#17120d_0%,#221a14_100%)]">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.featureLabel}</p>
<div className="mt-6 space-y-3">
@@ -310,7 +186,9 @@ export default function HomeContent() {
))}
</div>
</div>
) : <div />}
{hasSection('steps') ? (
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-stone-800 dark:bg-stone-900">
<div className="flex items-end justify-between gap-4">
<div>
@@ -320,7 +198,7 @@ export default function HomeContent() {
</div>
<div className="mt-8 space-y-4">
{dict.steps.map(([step, title, body]) => (
{dict.steps.map(({ step, title, body }) => (
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-stone-800 dark:bg-stone-950">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
{dict.stepLabel} {step}
@@ -331,10 +209,11 @@ export default function HomeContent() {
))}
</div>
</div>
) : <div />}
</div>
</section>
</section> : null}
<section className="shell pb-16 pt-6 sm:pb-20">
{hasSection('closing') ? <section className="shell pb-16 pt-6 sm:pb-20">
<div className="overflow-hidden rounded-[2rem] border border-stone-200 bg-stone-950 px-8 py-10 text-white dark:border-stone-700">
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
<div>
@@ -359,7 +238,7 @@ export default function HomeContent() {
</div>
</div>
</div>
</section>
</section> : null}
</main>
)
}
@@ -10,7 +10,7 @@ const frameConfig: Record<FrameId, { label: string; port: number; path: string }
dashboard: {
label: 'Company Workspace',
port: 3001,
path: '/',
path: '/sign-in',
},
admin: {
label: 'Platform Operations',
+12 -6
View File
@@ -1,6 +1,8 @@
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
interface BrandResponse {
company: { slug: string; name: string }
@@ -14,6 +16,9 @@ interface BrandResponse {
publicCountry: string | null
publicAddress: string | null
whatsappNumber: string | null
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
@@ -23,11 +28,12 @@ export default async function AboutPage() {
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
const companyName = brand?.brand?.displayName ?? brand?.company.name ?? dict.rentalCompanyFallback
const location = [brand?.brand?.publicCity, brand?.brand?.publicCountry].filter(Boolean).join(', ')
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
return (
<main className="min-h-screen bg-slate-50 py-16">
<div className="shell space-y-10">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
{pageSections.about.hero ? <section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.about.eyebrow}</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{companyName}</h1>
@@ -52,9 +58,9 @@ export default async function AboutPage() {
</div>
)}
</div>
</section>
</section> : null}
<section className="grid gap-6 md:grid-cols-3">
{pageSections.about.highlights ? <section className="grid gap-6 md:grid-cols-3">
<article className="card p-6">
<h2 className="text-xl font-black text-slate-900">{dict.about.directBookingTitle}</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.directBookingBody}</p>
@@ -67,14 +73,14 @@ export default async function AboutPage() {
<h2 className="text-xl font-black text-slate-900">{dict.about.marketplaceReadyTitle}</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.marketplaceReadyBody}</p>
</article>
</section>
</section> : null}
<section className="card p-8">
{pageSections.about.details ? <section className="card p-8">
<h2 className="text-2xl font-black text-slate-900">{dict.about.companyDetails}</h2>
<p className="mt-3 text-sm leading-7 text-slate-600">
{brand?.brand?.publicAddress ?? dict.about.companyAddressMissing}
</p>
</section>
</section> : null}
</div>
</main>
)
+18 -5
View File
@@ -1,21 +1,34 @@
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
export default function BlogPage() {
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export default async function BlogPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#fff, #f8fafc_35%)] py-16">
<div className="shell space-y-10">
<section className="max-w-3xl">
{pageSections.blog.hero ? <section className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.blog.eyebrow}</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.blog.title}</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
{dict.blog.description}
</p>
</section>
</section> : null}
<section className="grid gap-6 lg:grid-cols-3">
{pageSections.blog.posts ? <section className="grid gap-6 lg:grid-cols-3">
{dict.blog.posts.map((post) => (
<article key={post.title} className="card p-7">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700">{post.category}</p>
@@ -24,7 +37,7 @@ export default function BlogPage() {
<p className="mt-6 text-sm font-semibold text-slate-900">{dict.blog.draftPlaceholder}</p>
</article>
))}
</section>
</section> : null}
</div>
</main>
)
@@ -2,6 +2,8 @@ import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
interface ReservationDetail {
id: string
@@ -18,12 +20,22 @@ interface PageProps {
searchParams?: { id?: string }
}
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export const dynamic = 'force-dynamic'
export default async function BookingConfirmationPage({ searchParams = {} }: PageProps) {
const reservationId = searchParams.id
const slug = DEFAULT_COMPANY_SLUG
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${slug}/brand`, null)
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
const reservation = reservationId
? await siteFetchOrDefault<ReservationDetail | null>(
@@ -36,6 +48,8 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
<main className="min-h-screen bg-slate-50 py-16">
<div className="shell max-w-2xl">
<div className="card p-10 text-center">
{pageSections.bookingConfirmation.hero ? (
<>
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@@ -47,8 +61,10 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
<p className="mt-4 text-slate-600">
{dict.confirmation.description}
</p>
</>
) : null}
{reservation && (
{pageSections.bookingConfirmation.summary && reservation && (
<div className="mt-8 rounded-xl border border-slate-200 text-left divide-y divide-slate-100 overflow-hidden text-sm">
<div className="bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
{dict.confirmation.bookingSummary}
@@ -80,13 +96,13 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
</div>
)}
{!reservation && reservationId && (
{pageSections.bookingConfirmation.summary && !reservation && reservationId && (
<p className="mt-6 text-sm text-slate-500">
{dict.confirmation.reference}: <span className="font-mono">{reservationId}</span>
</p>
)}
<div className="mt-8 flex flex-wrap justify-center gap-3">
{pageSections.bookingConfirmation.actions ? <div className="mt-8 flex flex-wrap justify-center gap-3">
<Link
href="/vehicles"
className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white"
@@ -99,7 +115,7 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
>
{dict.confirmation.backHome}
</Link>
</div>
</div> : null}
</div>
</div>
</main>
+16 -1
View File
@@ -2,19 +2,34 @@ import { Suspense } from 'react'
import BookClient from './BookClient'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
export const dynamic = 'force-dynamic'
export default function BookPage() {
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export default async function BookPage() {
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell max-w-3xl">
{pageSections.booking.content ? (
<Suspense fallback={<div className="card p-8 text-sm text-slate-500">{dict.booking.loadingForm}</div>}>
<BookClient language={language} />
</Suspense>
) : null}
</div>
</main>
)
+16 -3
View File
@@ -1,16 +1,29 @@
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
export default function ContactPage() {
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export default async function ContactPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
return (
<main className="min-h-screen bg-slate-50 py-16">
<div className="shell max-w-2xl">
<div className="card p-8">
{pageSections.contact.content ? <div className="card p-8">
<h1 className="text-3xl font-black text-slate-900">{dict.contact.title}</h1>
<p className="mt-3 text-slate-600">{dict.contact.description}</p>
</div>
</div> : null}
</div>
</main>
)
+52 -14
View File
@@ -5,6 +5,7 @@ import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
import PublicLanguageSwitcher from '@/components/PublicLanguageSwitcher'
import type { PublicSitePageSections } from '@rentaldrivego/types'
export const metadata: Metadata = {
title: 'RentalDriveGo Public Site',
@@ -13,7 +14,36 @@ export const metadata: Metadata = {
interface BrandResponse {
company: { slug: string; name: string }
brand: { displayName: string; tagline: string | null; heroImageUrl: string | null } | null
brand: {
displayName: string
tagline: string | null
heroImageUrl: string | null
menuConfig?: {
aboutLabel?: string | null
vehiclesLabel?: string | null
offersLabel?: string | null
pricingLabel?: string | null
blogLabel?: string | null
contactLabel?: string | null
bookCarLabel?: string | null
siteNavigationLabel?: string | null
showAbout?: boolean | null
showVehicles?: boolean | null
showOffers?: boolean | null
showPricing?: boolean | null
showBlog?: boolean | null
showContact?: boolean | null
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
function resolveText(value: string | null | undefined, fallback: string) {
return value && value.trim() ? value : fallback
}
function resolveVisible(value: boolean | null | undefined) {
return value ?? true
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
@@ -26,6 +56,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
)
const companyName = brand?.brand?.displayName ?? brand?.company.name ?? 'RentalDriveGo'
const menuConfig = brand?.brand?.menuConfig ?? null
const headerMenuItems = [
{ href: '/about', label: resolveText(menuConfig?.aboutLabel, dict.nav.about), visible: resolveVisible(menuConfig?.showAbout) },
{ href: '/vehicles', label: resolveText(menuConfig?.vehiclesLabel, dict.nav.vehicles), visible: resolveVisible(menuConfig?.showVehicles) },
{ href: '/offers', label: resolveText(menuConfig?.offersLabel, dict.nav.offers), visible: resolveVisible(menuConfig?.showOffers) },
{ href: '/pricing', label: resolveText(menuConfig?.pricingLabel, dict.nav.pricing), visible: resolveVisible(menuConfig?.showPricing) },
{ href: '/blog', label: resolveText(menuConfig?.blogLabel, dict.nav.blog), visible: resolveVisible(menuConfig?.showBlog) },
{ href: '/contact', label: resolveText(menuConfig?.contactLabel, dict.nav.contact), visible: resolveVisible(menuConfig?.showContact) },
]
const footerLabel = resolveText(menuConfig?.siteNavigationLabel, dict.nav.siteNavigation)
const bookCarLabel = resolveText(menuConfig?.bookCarLabel, dict.nav.bookCar)
return (
<html lang={dict.lang} dir={dict.dir}>
@@ -43,11 +84,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</Link>
<nav className="flex items-center gap-1">
<NavLink href="/about">{dict.nav.about}</NavLink>
<NavLink href="/vehicles">{dict.nav.vehicles}</NavLink>
<NavLink href="/offers">{dict.nav.offers}</NavLink>
<NavLink href="/pricing">{dict.nav.pricing}</NavLink>
<NavLink href="/contact">{dict.nav.contact}</NavLink>
{headerMenuItems.filter((item) => item.visible).map((item) => (
<NavLink key={item.href} href={item.href}>{item.label}</NavLink>
))}
</nav>
<div className="flex items-center gap-3">
@@ -56,7 +95,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
href="/vehicles"
className="hidden rounded-full bg-slate-900 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 sm:inline-flex"
>
{dict.nav.bookCar}
{bookCarLabel}
</Link>
</div>
</div>
@@ -67,16 +106,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<footer className="mt-16 border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
<div className="shell flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
{dict.nav.siteNavigation}
{footerLabel}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<nav className="flex flex-wrap items-center justify-center gap-2 text-sm text-stone-500">
<Link href="/about" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.about}</Link>
<Link href="/vehicles" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.vehicles}</Link>
<Link href="/offers" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.offers}</Link>
<Link href="/pricing" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.pricing}</Link>
<Link href="/blog" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.blog}</Link>
<Link href="/contact" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.contact}</Link>
{headerMenuItems.filter((item) => item.visible).map((item) => (
<Link key={item.href} href={item.href} className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">
{item.label}
</Link>
))}
</nav>
<p className="text-sm text-stone-500">
&copy; {new Date().getFullYear()} {companyName}
+18 -4
View File
@@ -2,6 +2,8 @@ import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetch } from '@/lib/api'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
interface Offer {
id: string
@@ -11,14 +13,26 @@ interface Offer {
validUntil: string
}
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export default async function OffersPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const offers = await siteFetch<Offer[]>(`/site/${DEFAULT_COMPANY_SLUG}/offers`).catch(() => [])
const [offers, brand] = await Promise.all([
siteFetch<Offer[]>(`/site/${DEFAULT_COMPANY_SLUG}/offers`).catch(() => []),
siteFetch<BrandResponse>(`/site/${DEFAULT_COMPANY_SLUG}/brand`).catch(() => null),
])
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell">
<h1 className="text-4xl font-black text-slate-900">{dict.offers.title}</h1>
<div className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{pageSections.offers.header ? <h1 className="text-4xl font-black text-slate-900">{dict.offers.title}</h1> : null}
{pageSections.offers.grid ? <div className={`${pageSections.offers.header ? 'mt-8' : ''} grid gap-5 md:grid-cols-2 xl:grid-cols-3`}>
{offers.map((offer) => (
<div key={offer.id} className="card p-6">
<h2 className="text-xl font-bold text-slate-900">{offer.title}</h2>
@@ -28,7 +42,7 @@ export default async function OffersPage() {
</div>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{dict.offers.empty}</div>}
</div>
</div> : null}
</div>
</main>
)
+218 -16
View File
@@ -1,12 +1,46 @@
import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import {
formatCurrency,
resolvePublicHomepageLayout,
type PublicHomepageLayout,
type PublicHomepageSectionType,
} from '@rentaldrivego/types'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { getPublicSiteDictionary } from '@/lib/i18n'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
interface BrandResponse {
company: { slug: string; name: string }
brand: { displayName: string; tagline: string | null; heroImageUrl: string | null; publicPhone: string | null } | null
brand: {
displayName: string
tagline: string | null
heroImageUrl: string | null
publicPhone: string | null
homePageConfig?: {
heroTitle?: string | null
heroDescription?: string | null
viewOffersLabel?: string | null
viewPricingLabel?: string | null
contactCompanyLabel?: string | null
activeOffersTitle?: string | null
seeAllOffersLabel?: string | null
noActiveOffersLabel?: string | null
publishedVehiclesTitle?: string | null
viewVehicleLabel?: string | null
pricingEyebrow?: string | null
pricingTitle?: string | null
pricingDescription?: string | null
showOffers?: boolean | null
showVehicles?: boolean | null
showPricing?: boolean | null
layout?: PublicHomepageLayout | null
} | null
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
interface Vehicle {
@@ -24,27 +58,103 @@ interface Offer {
discountValue: number
}
type BrandHomePageConfig = NonNullable<BrandResponse['brand']>['homePageConfig']
const pricingPlans = [
{
name: 'Starter',
price: 'MAD 499',
cadence: '/month',
},
{
name: 'Growth',
price: 'MAD 999',
cadence: '/month',
highlighted: true,
},
{
name: 'Pro',
price: 'Custom',
cadence: '',
},
]
function resolveText(value: string | null | undefined, fallback: string) {
return value && value.trim() ? value : fallback
}
function resolveVisible(value: boolean | null | undefined) {
return value ?? true
}
function resolveHomepageSectionVisibility(
type: PublicHomepageSectionType,
pageSections: PublicSitePageSections,
homeConfig: BrandHomePageConfig | null | undefined,
) {
if (!pageSections.home[type]) return false
if (type === 'hero') return true
if (type === 'offers') return resolveVisible(homeConfig?.showOffers)
if (type === 'vehicles') return resolveVisible(homeConfig?.showVehicles)
return resolveVisible(homeConfig?.showPricing)
}
export default async function PublicHomePage() {
const slug = DEFAULT_COMPANY_SLUG
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const localizedPlans = pricingPlans.map((plan) => ({
...plan,
description:
plan.name === 'Starter'
? dict.pricing.plans.starterDescription
: plan.name === 'Growth'
? dict.pricing.plans.growthDescription
: dict.pricing.plans.proDescription,
features:
plan.name === 'Starter'
? dict.pricing.plans.starterFeatures
: plan.name === 'Growth'
? dict.pricing.plans.growthFeatures
: dict.pricing.plans.proFeatures,
}))
const [brand, vehicles, offers] = await Promise.all([
siteFetch<BrandResponse>(`/site/${slug}/brand`).catch(() => null),
siteFetch<Vehicle[]>(`/site/${slug}/vehicles`).catch(() => []),
siteFetch<Offer[]>(`/site/${slug}/offers`).catch(() => []),
])
const homeConfig = brand?.brand?.homePageConfig ?? null
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
const heroTitle = resolveText(homeConfig?.heroTitle, brand?.brand?.tagline ?? dict.home.heroTitleFallback)
const heroDescription = resolveText(homeConfig?.heroDescription, dict.home.heroDescription)
const viewOffersLabel = resolveText(homeConfig?.viewOffersLabel, dict.home.viewOffers)
const viewPricingLabel = resolveText(homeConfig?.viewPricingLabel, dict.home.viewPricing)
const contactCompanyLabel = resolveText(homeConfig?.contactCompanyLabel, dict.home.contactCompany)
const activeOffersTitle = resolveText(homeConfig?.activeOffersTitle, dict.home.activeOffers)
const seeAllOffersLabel = resolveText(homeConfig?.seeAllOffersLabel, dict.home.seeAllOffers)
const noActiveOffersLabel = resolveText(homeConfig?.noActiveOffersLabel, dict.home.noActiveOffers)
const publishedVehiclesTitle = resolveText(homeConfig?.publishedVehiclesTitle, dict.home.publishedVehicles)
const viewVehicleLabel = resolveText(homeConfig?.viewVehicleLabel, dict.home.viewVehicle)
const pricingEyebrow = resolveText(homeConfig?.pricingEyebrow, dict.pricing.eyebrow)
const pricingTitle = resolveText(homeConfig?.pricingTitle, dict.pricing.title)
const pricingDescription = resolveText(homeConfig?.pricingDescription, dict.pricing.description)
const visibleHomepageTypes = (['hero', 'offers', 'vehicles', 'pricing'] as PublicHomepageSectionType[]).filter((type) =>
resolveHomepageSectionVisibility(type, pageSections, homeConfig),
)
const homepageLayout = resolvePublicHomepageLayout(homeConfig?.layout, visibleHomepageTypes)
function renderHomepageSection(type: PublicHomepageSectionType) {
if (type === 'hero') {
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_30%)]">
<div className="shell py-16 space-y-12">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr] items-center">
<section className="grid h-full gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{brand?.brand?.displayName ?? brand?.company.name ?? dict.siteNameFallback}</p>
<h1 className="mt-5 text-5xl font-black tracking-tight text-slate-900">{brand?.brand?.tagline ?? dict.home.heroTitleFallback}</h1>
<p className="mt-6 text-lg leading-8 text-slate-600">{dict.home.heroDescription}</p>
<h1 className="mt-5 text-5xl font-black tracking-tight text-slate-900">{heroTitle}</h1>
<p className="mt-6 text-lg leading-8 text-slate-600">{heroDescription}</p>
<div className="mt-10 flex flex-wrap gap-4">
<Link href="/offers" className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{dict.home.viewOffers}</Link>
<Link href="/contact" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{dict.home.contactCompany}</Link>
<Link href="/offers" className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{viewOffersLabel}</Link>
<Link href="/pricing" className="rounded-full border border-sky-200 bg-sky-50 px-6 py-3 text-sm font-semibold text-sky-800">{viewPricingLabel}</Link>
<Link href="/contact" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{contactCompanyLabel}</Link>
</div>
</div>
<div className="card overflow-hidden bg-slate-100 min-h-80">
@@ -56,11 +166,15 @@ export default async function PublicHomePage() {
)}
</div>
</section>
)
}
<section>
if (type === 'offers') {
return (
<section className="h-full">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">{dict.home.activeOffers}</h2>
<Link href="/offers" className="text-sm font-semibold text-sky-700">{dict.home.seeAllOffers}</Link>
<h2 className="text-2xl font-bold text-slate-900">{activeOffersTitle}</h2>
<Link href="/offers" className="text-sm font-semibold text-sky-700">{seeAllOffersLabel}</Link>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{offers.map((offer) => (
@@ -69,13 +183,17 @@ export default async function PublicHomePage() {
<p className="mt-3 text-3xl font-black text-sky-700">{offer.discountValue}%</p>
</div>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{dict.home.noActiveOffers}</div>}
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{noActiveOffersLabel}</div>}
</div>
</section>
)
}
<section>
if (type === 'vehicles') {
return (
<section className="h-full">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">{dict.home.publishedVehicles}</h2>
<h2 className="text-2xl font-bold text-slate-900">{publishedVehiclesTitle}</h2>
<p className="text-sm text-slate-500">{dict.home.available(vehicles.length)}</p>
</div>
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
@@ -92,13 +210,97 @@ export default async function PublicHomePage() {
<p className="mt-1 text-sm text-slate-500">{vehicle.category}</p>
<div className="mt-4 flex items-end justify-between">
<p className="text-xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
<Link href={`/vehicles/${vehicle.id}`} className="rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.home.viewVehicle}</Link>
<Link href={`/vehicles/${vehicle.id}`} className="rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white">{viewVehicleLabel}</Link>
</div>
</div>
</article>
))}
</div>
</section>
)
}
return (
<section className="h-full space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{pricingEyebrow}</p>
<h2 className="mt-2 text-3xl font-black tracking-tight text-slate-900">{pricingTitle}</h2>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-600">{pricingDescription}</p>
</div>
<Link href="/pricing" className="rounded-full border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-700">
{viewPricingLabel}
</Link>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{localizedPlans.map((plan) => (
<article
key={plan.name}
className={`card p-8 ${plan.highlighted ? 'border-sky-300 shadow-lg shadow-sky-100' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-2xl font-black text-slate-900">{plan.name}</h3>
<p className="mt-3 text-sm leading-6 text-slate-600">{plan.description}</p>
</div>
{plan.highlighted ? (
<span className="rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-sky-800">
{dict.pricing.popular}
</span>
) : null}
</div>
<div className="mt-8 flex items-end gap-2">
<span className="text-4xl font-black tracking-tight text-slate-900">{plan.price}</span>
{plan.cadence ? <span className="pb-1 text-sm text-slate-500">{plan.cadence}</span> : null}
</div>
<ul className="mt-8 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-3 text-sm text-slate-700">
<span className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-sky-100 text-xs font-bold text-sky-700">+</span>
<span>{feature}</span>
</li>
))}
</ul>
</article>
))}
</div>
</section>
)
}
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_30%)]">
<div className="shell py-16">
<div className="grid gap-6 lg:hidden">
{homepageLayout.items.map((item) => (
<div key={`mobile-${item.id}`} className="rounded-[1.75rem] border border-slate-200/80 bg-white/92 p-5 shadow-lg shadow-slate-200/60">
{renderHomepageSection(item.type)}
</div>
))}
</div>
<div
className="relative hidden lg:block"
style={{ height: `${homepageLayout.items.reduce((max, item) => Math.max(max, item.y + item.h - 1), 1) * 80}px` }}
>
{homepageLayout.items.map((item) => (
<div
key={item.id}
className="absolute rounded-[1.75rem] border border-slate-200/80 bg-white/92 p-6 shadow-lg shadow-slate-200/60"
style={{
left: `${((item.x - 1) / 12) * 100}%`,
top: `${(item.y - 1) * 80}px`,
width: `${(item.w / 12) * 100}%`,
height: `${item.h * 80}px`,
}}
>
{renderHomepageSection(item.type)}
</div>
))}
</div>
</div>
</main>
)
+22 -9
View File
@@ -1,5 +1,8 @@
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
const plans = [
{
@@ -20,8 +23,18 @@ const plans = [
},
]
export default function PricingPage() {
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export default async function PricingPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
const localizedPlans = plans.map((plan) => ({
...plan,
description:
@@ -55,15 +68,15 @@ export default function PricingPage() {
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_18%,#f8fafc)] py-16">
<div className="shell space-y-12">
<section className="mx-auto max-w-3xl text-center">
{pageSections.pricing.hero ? <section className="mx-auto max-w-3xl text-center">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.pricing.eyebrow}</p>
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.pricing.title}</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
{dict.pricing.description}
</p>
</section>
</section> : null}
<section className="grid gap-6 lg:grid-cols-3">
{pageSections.pricing.plans ? <section className="grid gap-6 lg:grid-cols-3">
{localizedPlans.map((plan) => (
<article
key={plan.name}
@@ -96,9 +109,9 @@ export default function PricingPage() {
</ul>
</article>
))}
</section>
</section> : null}
<section className="card p-8">
{pageSections.pricing.payments ? <section className="card p-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-black text-slate-900">{dict.pricing.acceptedPaymentMethods}</h2>
@@ -110,16 +123,16 @@ export default function PricingPage() {
<span className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-700">MAD · USD · EUR</span>
</div>
</div>
</section>
</section> : null}
<section className="grid gap-4">
{pageSections.pricing.faq ? <section className="grid gap-4">
{faqs.map((faq) => (
<article key={faq.question} className="card p-6">
<h3 className="text-lg font-bold text-slate-900">{faq.question}</h3>
<p className="mt-2 text-sm leading-7 text-slate-600">{faq.answer}</p>
</article>
))}
</section>
</section> : null}
</div>
</main>
)
@@ -3,6 +3,8 @@ import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
interface VehicleDetail {
id: string
@@ -18,9 +20,21 @@ interface VehicleDetail {
photos: string[]
}
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export default async function VehiclePage({ params }: { params: { id: string } }) {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const vehicle = await siteFetchOrDefault<VehicleDetail | null>(`/site/${DEFAULT_COMPANY_SLUG}/vehicles/${params.id}`, null)
const [vehicle, brand] = await Promise.all([
siteFetchOrDefault<VehicleDetail | null>(`/site/${DEFAULT_COMPANY_SLUG}/vehicles/${params.id}`, null),
siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null),
])
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
if (!vehicle) {
return (
@@ -41,16 +55,16 @@ export default async function VehiclePage({ params }: { params: { id: string } }
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<section className="grid gap-4 sm:grid-cols-2">
<div className={`shell grid gap-8 ${pageSections.vehicleDetail.gallery && pageSections.vehicleDetail.summary ? 'lg:grid-cols-[1.2fr_0.8fr]' : ''}`}>
{pageSections.vehicleDetail.gallery ? <section className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="card overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 1}`} className="h-full w-full object-cover" />
</div>
))}
</section>
<aside className="card p-6 h-fit">
</section> : null}
{pageSections.vehicleDetail.summary ? <aside className="card p-6 h-fit">
<h1 className="text-4xl font-black text-slate-900">{vehicle.make} {vehicle.model}</h1>
<p className="mt-3 text-slate-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
<p className="mt-6 text-3xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-slate-500"> {dict.vehicles.detail.perDay}</span></p>
@@ -63,7 +77,7 @@ export default async function VehiclePage({ params }: { params: { id: string } }
<Link href={`/book?vehicleId=${vehicle.id}`} className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{dict.vehicles.detail.bookNow}</Link>
<Link href="/" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{dict.vehicles.detail.back}</Link>
</div>
</aside>
</aside> : null}
</div>
</main>
)
+22 -8
View File
@@ -4,6 +4,8 @@ import { formatCurrency } from '@rentaldrivego/types'
import VehicleFilters from './VehicleFilters'
import { getPublicSiteDictionary } from '@/lib/i18n'
import { getPublicSiteLanguage } from '@/lib/i18n.server'
import type { PublicSitePageSections } from '@rentaldrivego/types'
import { resolvePageSectionsConfig } from '@/lib/pageSections'
interface Vehicle {
id: string
@@ -18,6 +20,14 @@ interface Vehicle {
photos: string[]
}
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
interface PageProps {
searchParams?: {
category?: string
@@ -32,7 +42,11 @@ export default async function VehiclesPage({ searchParams = {} }: PageProps) {
const slug = DEFAULT_COMPANY_SLUG
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const vehicles = await siteFetchOrDefault<Vehicle[]>(`/site/${slug}/vehicles`, [])
const [vehicles, brand] = await Promise.all([
siteFetchOrDefault<Vehicle[]>(`/site/${slug}/vehicles`, []),
siteFetchOrDefault<BrandResponse | null>(`/site/${slug}/brand`, null),
])
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
const categories = Array.from(new Set(vehicles.map((v) => v.category))).sort()
const transmissions = Array.from(new Set(vehicles.map((v) => v.transmission))).sort()
@@ -48,15 +62,15 @@ export default async function VehiclesPage({ searchParams = {} }: PageProps) {
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell">
<div className="mb-8">
{pageSections.vehicles.header ? <div className="mb-8">
<h1 className="text-4xl font-black text-slate-900">{dict.vehicles.title}</h1>
<p className="mt-2 text-slate-500">
{dict.vehicles.available(filtered.length)}
</p>
</div>
</div> : null}
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
{/* Filter sidebar (client component) */}
<div className={`grid gap-8 ${pageSections.vehicles.filters ? 'lg:grid-cols-[260px_1fr]' : ''}`}>
{pageSections.vehicles.filters ? (
<aside>
<VehicleFilters
language={language}
@@ -70,9 +84,9 @@ export default async function VehiclesPage({ searchParams = {} }: PageProps) {
}}
/>
</aside>
) : null}
{/* Vehicle grid */}
<section>
{pageSections.vehicles.grid ? <section>
{filtered.length === 0 ? (
<div className="card p-10 text-center">
<p className="text-lg font-semibold text-slate-700">{dict.vehicles.noVehiclesTitle}</p>
@@ -133,7 +147,7 @@ export default async function VehiclesPage({ searchParams = {} }: PageProps) {
))}
</div>
)}
</section>
</section> : null}
</div>
</div>
</main>
+4
View File
@@ -25,6 +25,7 @@ export type PublicSiteDictionary = {
heroTitleFallback: string
heroDescription: string
viewOffers: string
viewPricing: string
contactCompany: string
heroImageMissing: string
activeOffers: string
@@ -261,6 +262,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
heroTitleFallback: 'Book directly with the rental company.',
heroDescription: 'This branded site handles booking and payment directly. Marketplace discovery routes renters here for checkout.',
viewOffers: 'View offers',
viewPricing: 'View pricing',
contactCompany: 'Contact company',
heroImageMissing: 'Hero image not configured yet.',
activeOffers: 'Active offers',
@@ -507,6 +509,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
heroTitleFallback: "Réservez directement auprès de l'entreprise de location.",
heroDescription: 'Ce site de marque gère directement la réservation et le paiement. La découverte sur la marketplace redirige les locataires ici pour finaliser la commande.',
viewOffers: 'Voir les offres',
viewPricing: 'Voir les tarifs',
contactCompany: "Contacter l'entreprise",
heroImageMissing: "L'image principale n'est pas encore configurée.",
activeOffers: 'Offres actives',
@@ -753,6 +756,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
heroTitleFallback: 'احجز مباشرة مع شركة التأجير.',
heroDescription: 'هذا الموقع المخصص للعلامة التجارية يدير الحجز والدفع مباشرة. الاكتشاف عبر المنصة يحول المستأجرين إلى هنا لإتمام الحجز.',
viewOffers: 'عرض العروض',
viewPricing: 'عرض الأسعار',
contactCompany: 'التواصل مع الشركة',
heroImageMissing: 'لم يتم إعداد صورة رئيسية بعد.',
activeOffers: 'العروض النشطة',
+29
View File
@@ -0,0 +1,29 @@
import {
clonePublicSitePageSections,
type PublicSitePageSections,
} from '@rentaldrivego/types'
type MaybePageSections = {
pageSections?: Partial<PublicSitePageSections> | null
} | null | undefined
export function resolvePageSectionsConfig(menuConfig: MaybePageSections): PublicSitePageSections {
const defaults = clonePublicSitePageSections()
const custom = menuConfig?.pageSections ?? {}
return {
home: { ...defaults.home, ...(custom.home ?? {}) },
about: { ...defaults.about, ...(custom.about ?? {}) },
offers: { ...defaults.offers, ...(custom.offers ?? {}) },
pricing: { ...defaults.pricing, ...(custom.pricing ?? {}) },
vehicles: { ...defaults.vehicles, ...(custom.vehicles ?? {}) },
vehicleDetail: { ...defaults.vehicleDetail, ...(custom.vehicleDetail ?? {}) },
blog: { ...defaults.blog, ...(custom.blog ?? {}) },
contact: { ...defaults.contact, ...(custom.contact ?? {}) },
booking: { ...defaults.booking, ...(custom.booking ?? {}) },
bookingConfirmation: {
...defaults.bookingConfirmation,
...(custom.bookingConfirmation ?? {}),
},
}
}
+2
View File
@@ -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
+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
}