add homepage management
This commit is contained in:
@@ -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 }),
|
||||
})
|
||||
|
||||
@@ -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 l’entreprise',
|
||||
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 à l’action final',
|
||||
previewTitle: 'Aperçu en direct',
|
||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||
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 d’ensemble',
|
||||
companies: 'Entreprises',
|
||||
siteConfig: 'Config site',
|
||||
renters: 'Locataires',
|
||||
auditLogs: 'Journaux d’audit',
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
@@ -49,6 +51,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
nav: {
|
||||
overview: 'نظرة عامة',
|
||||
companies: 'الشركات',
|
||||
siteConfig: 'إعدادات الموقع',
|
||||
renters: 'المستأجرون',
|
||||
auditLogs: 'سجلات التدقيق',
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
{
|
||||
"marketplaceHomepage": {
|
||||
"en": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
||||
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.",
|
||||
"startTrial": "Start free trial",
|
||||
"exploreVehicles": "Explore vehicles",
|
||||
"surfaceLabel": "Marketplace surface",
|
||||
"surfaceTitle": "Designed for two audiences at once.",
|
||||
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
|
||||
"liveLabel": "Live network",
|
||||
"trustedFleets": "Trusted fleets",
|
||||
"brandedFlows": "Branded booking flows",
|
||||
"multiTenant": "Multi-tenant operations",
|
||||
"companyKicker": "Operator workflow",
|
||||
"companyTitle": "Control inventory, offers, billing, and staff from one command layer.",
|
||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||
"renterKicker": "Renter experience",
|
||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Unified publishing",
|
||||
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
|
||||
},
|
||||
{
|
||||
"title": "Qualified discovery",
|
||||
"body": "Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast."
|
||||
},
|
||||
{
|
||||
"title": "Direct revenue path",
|
||||
"body": "Bookings move into the company’s own payment flow, so customer ownership and cash collection stay with the business."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Shared marketplace visibility"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "Direct company checkout"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "Company-owned payments"
|
||||
}
|
||||
],
|
||||
"featureLabel": "What the product covers",
|
||||
"features": [
|
||||
"Fleet management with multi-photo uploads",
|
||||
"Marketplace offers and redirect booking flow",
|
||||
"Branded public booking site per company",
|
||||
"Customer CRM, analytics, and billing controls"
|
||||
],
|
||||
"stepsTitle": "How companies launch",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Create your company workspace",
|
||||
"body": "Pick a plan, launch your 14-day trial, and verify the owner account."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publish vehicles and offers",
|
||||
"body": "Upload photos once and control what appears on the marketplace and branded site."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "Accept bookings on your own site",
|
||||
"body": "Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow."
|
||||
}
|
||||
],
|
||||
"stepLabel": "Step",
|
||||
"readyKicker": "Ready to launch",
|
||||
"readyTitle": "A marketplace homepage should sell the system in seconds.",
|
||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.",
|
||||
"viewPricing": "View pricing",
|
||||
"createWorkspace": "Create workspace"
|
||||
},
|
||||
"fr": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "Une vitrine marketplace plus nette et plus forte.",
|
||||
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.",
|
||||
"startTrial": "Commencer l’essai gratuit",
|
||||
"exploreVehicles": "Explorer les véhicules",
|
||||
"surfaceLabel": "Surface marketplace",
|
||||
"surfaceTitle": "Pensée pour deux audiences à la fois.",
|
||||
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
|
||||
"liveLabel": "Réseau actif",
|
||||
"trustedFleets": "Flottes fiables",
|
||||
"brandedFlows": "Parcours de marque",
|
||||
"multiTenant": "Opérations multi-tenant",
|
||||
"companyKicker": "Flux opérateur",
|
||||
"companyTitle": "Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.",
|
||||
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
|
||||
"renterKicker": "Expérience client",
|
||||
"renterTitle": "Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.",
|
||||
"renterBody": "La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "Publication unifiée",
|
||||
"body": "Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque."
|
||||
},
|
||||
{
|
||||
"title": "Découverte qualifiée",
|
||||
"body": "Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement."
|
||||
},
|
||||
{
|
||||
"title": "Revenus en direct",
|
||||
"body": "Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "Visibilité marketplace partagée"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "Paiement direct entreprise"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "Paiements détenus par la société"
|
||||
}
|
||||
],
|
||||
"featureLabel": "Ce que couvre le produit",
|
||||
"features": [
|
||||
"Gestion de flotte avec téléversement multi-photos",
|
||||
"Offres marketplace et redirection vers la réservation",
|
||||
"Site public de réservation par entreprise",
|
||||
"CRM client, analytics et contrôle de facturation"
|
||||
],
|
||||
"stepsTitle": "Comment les entreprises démarrent",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "Créez votre espace entreprise",
|
||||
"body": "Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "Publiez véhicules et offres",
|
||||
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "Acceptez les réservations sur votre site",
|
||||
"body": "Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation."
|
||||
}
|
||||
],
|
||||
"stepLabel": "Étape",
|
||||
"readyKicker": "Prêt à démarrer",
|
||||
"readyTitle": "Une homepage marketplace doit vendre le système en quelques secondes.",
|
||||
"readyBody": "Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.",
|
||||
"viewPricing": "Voir les tarifs",
|
||||
"createWorkspace": "Créer l’espace"
|
||||
},
|
||||
"ar": {
|
||||
"sections": [
|
||||
"hero",
|
||||
"surface",
|
||||
"pillars",
|
||||
"audiences",
|
||||
"features",
|
||||
"steps",
|
||||
"closing"
|
||||
],
|
||||
"heroKicker": "RentalDriveGo",
|
||||
"heroTitle": "واجهة سوق أوضح وأقوى.",
|
||||
"heroBody": "شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.",
|
||||
"startTrial": "ابدأ التجربة المجانية",
|
||||
"exploreVehicles": "استكشف السيارات",
|
||||
"surfaceLabel": "واجهة السوق",
|
||||
"surfaceTitle": "مصممة لجمهورين في الوقت نفسه.",
|
||||
"surfaceBody": "المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.",
|
||||
"liveLabel": "شبكة نشطة",
|
||||
"trustedFleets": "أساطيل موثوقة",
|
||||
"brandedFlows": "مسارات حجز مخصصة",
|
||||
"multiTenant": "عمليات متعددة الشركات",
|
||||
"companyKicker": "تدفق المشغل",
|
||||
"companyTitle": "تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.",
|
||||
"companyBody": "انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.",
|
||||
"renterKicker": "تجربة المستأجر",
|
||||
"renterTitle": "دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.",
|
||||
"renterBody": "السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.",
|
||||
"pillars": [
|
||||
{
|
||||
"title": "نشر موحد",
|
||||
"body": "صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة."
|
||||
},
|
||||
{
|
||||
"title": "اكتشاف مؤهل",
|
||||
"body": "العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة."
|
||||
},
|
||||
{
|
||||
"title": "مسار إيراد مباشر",
|
||||
"body": "تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه."
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"value": "01",
|
||||
"label": "ظهور مشترك في السوق"
|
||||
},
|
||||
{
|
||||
"value": "02",
|
||||
"label": "دفع مباشر للشركة"
|
||||
},
|
||||
{
|
||||
"value": "03",
|
||||
"label": "مدفوعات مملوكة للشركة"
|
||||
}
|
||||
],
|
||||
"featureLabel": "ما الذي يغطيه المنتج",
|
||||
"features": [
|
||||
"إدارة الأسطول مع رفع عدة صور",
|
||||
"عروض السوق والتحويل إلى مسار الحجز",
|
||||
"موقع حجز عام مخصص لكل شركة",
|
||||
"إدارة العملاء والتحليلات والفوترة"
|
||||
],
|
||||
"stepsTitle": "كيف تبدأ الشركات",
|
||||
"steps": [
|
||||
{
|
||||
"step": "1",
|
||||
"title": "أنشئ مساحة شركتك",
|
||||
"body": "اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك."
|
||||
},
|
||||
{
|
||||
"step": "2",
|
||||
"title": "انشر السيارات والعروض",
|
||||
"body": "ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص."
|
||||
},
|
||||
{
|
||||
"step": "3",
|
||||
"title": "استقبل الحجوزات على موقعك",
|
||||
"body": "يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك."
|
||||
}
|
||||
],
|
||||
"stepLabel": "الخطوة",
|
||||
"readyKicker": "جاهز للانطلاق",
|
||||
"readyTitle": "يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.",
|
||||
"readyBody": "استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.",
|
||||
"viewPricing": "عرض الأسعار",
|
||||
"createWorkspace": "إنشاء المساحة"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const getClientIpKey = (req: Parameters<typeof ipKeyGenerator>[0]) =>
|
||||
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
@@ -20,7 +20,7 @@ export const authLimiter = rateLimit({
|
||||
// Standard limiter for general authenticated API endpoints
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 120,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
@@ -35,7 +35,7 @@ export const apiLimiter = rateLimit({
|
||||
// Tight limiter for public marketplace and site endpoints (no auth)
|
||||
export const publicLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
@@ -45,7 +45,7 @@ export const publicLimiter = rateLimit({
|
||||
// Tight limiter for admin endpoints
|
||||
export const adminLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 30,
|
||||
max: 960,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => getClientIpKey(req),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { authenticator } from 'otplib'
|
||||
import qrcode from 'qrcode'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { requireAdminAuth, requireAdminRole } from '../middleware/requireAdminAuth'
|
||||
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../services/platformContentService'
|
||||
import type { MarketplaceHomepageContent, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep } from '@rentaldrivego/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -14,6 +16,244 @@ const permissionSchema = z.object({
|
||||
actions: z.array(z.enum(['READ', 'CREATE', 'UPDATE', 'DELETE', 'SUSPEND', 'IMPERSONATE'])).min(1),
|
||||
})
|
||||
|
||||
const nullableString = z.union([z.string(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
|
||||
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
|
||||
const nullableDate = z.union([
|
||||
z.string().datetime(),
|
||||
z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
z.null(),
|
||||
]).optional()
|
||||
|
||||
const homePageConfigSchema = z.object({
|
||||
heroTitle: nullableString,
|
||||
heroDescription: nullableString,
|
||||
viewOffersLabel: nullableString,
|
||||
viewPricingLabel: nullableString,
|
||||
contactCompanyLabel: nullableString,
|
||||
activeOffersTitle: nullableString,
|
||||
seeAllOffersLabel: nullableString,
|
||||
noActiveOffersLabel: nullableString,
|
||||
publishedVehiclesTitle: nullableString,
|
||||
viewVehicleLabel: nullableString,
|
||||
pricingEyebrow: nullableString,
|
||||
pricingTitle: nullableString,
|
||||
pricingDescription: nullableString,
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional()
|
||||
|
||||
const menuConfigSchema = z.object({
|
||||
aboutLabel: nullableString,
|
||||
vehiclesLabel: nullableString,
|
||||
offersLabel: nullableString,
|
||||
pricingLabel: nullableString,
|
||||
blogLabel: nullableString,
|
||||
contactLabel: nullableString,
|
||||
bookCarLabel: nullableString,
|
||||
siteNavigationLabel: nullableString,
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(),
|
||||
pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
highlights: z.boolean().optional(),
|
||||
details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({
|
||||
header: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(),
|
||||
faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(),
|
||||
filters: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({
|
||||
gallery: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
}).optional(),
|
||||
blog: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
posts: z.boolean().optional(),
|
||||
}).optional(),
|
||||
contact: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
booking: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional()
|
||||
|
||||
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({
|
||||
step: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum([
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
'audiences',
|
||||
'features',
|
||||
'steps',
|
||||
'closing',
|
||||
])
|
||||
|
||||
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
|
||||
sections: z.array(marketplaceSectionSchema).min(1),
|
||||
heroKicker: z.string().min(1),
|
||||
heroTitle: z.string().min(1),
|
||||
heroBody: z.string().min(1),
|
||||
startTrial: z.string().min(1),
|
||||
exploreVehicles: z.string().min(1),
|
||||
surfaceLabel: z.string().min(1),
|
||||
surfaceTitle: z.string().min(1),
|
||||
surfaceBody: z.string().min(1),
|
||||
liveLabel: z.string().min(1),
|
||||
trustedFleets: z.string().min(1),
|
||||
brandedFlows: z.string().min(1),
|
||||
multiTenant: z.string().min(1),
|
||||
companyKicker: z.string().min(1),
|
||||
companyTitle: z.string().min(1),
|
||||
companyBody: z.string().min(1),
|
||||
renterKicker: z.string().min(1),
|
||||
renterTitle: z.string().min(1),
|
||||
renterBody: z.string().min(1),
|
||||
pillars: z.array(marketplacePillarSchema).length(3),
|
||||
metrics: z.array(marketplaceMetricSchema).length(3),
|
||||
featureLabel: z.string().min(1),
|
||||
features: z.array(z.string().min(1)).min(1),
|
||||
stepsTitle: z.string().min(1),
|
||||
steps: z.array(marketplaceStepSchema).length(3),
|
||||
stepLabel: z.string().min(1),
|
||||
readyKicker: z.string().min(1),
|
||||
readyTitle: z.string().min(1),
|
||||
readyBody: z.string().min(1),
|
||||
viewPricing: z.string().min(1),
|
||||
createWorkspace: z.string().min(1),
|
||||
})
|
||||
|
||||
const marketplaceHomepageConfigSchema = z.object({
|
||||
en: marketplaceHomepageContentSchema,
|
||||
fr: marketplaceHomepageContentSchema,
|
||||
ar: marketplaceHomepageContentSchema,
|
||||
})
|
||||
|
||||
const adminCompanyUpdateSchema = z.object({
|
||||
company: z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
slug: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: nullableString,
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
}).optional(),
|
||||
subscription: z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
|
||||
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
trialStartAt: nullableDate,
|
||||
trialEndAt: nullableDate,
|
||||
currentPeriodStart: nullableDate,
|
||||
currentPeriodEnd: nullableDate,
|
||||
cancelledAt: nullableDate,
|
||||
cancelAtPeriodEnd: z.boolean().optional(),
|
||||
}).optional(),
|
||||
brand: z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: nullableString,
|
||||
subdomain: z.string().min(1).optional(),
|
||||
customDomain: nullableString,
|
||||
publicEmail: nullableEmail,
|
||||
publicPhone: nullableString,
|
||||
publicAddress: nullableString,
|
||||
publicCity: nullableString,
|
||||
publicCountry: nullableString,
|
||||
websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString,
|
||||
defaultLocale: z.string().min(2).optional(),
|
||||
defaultCurrency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: homePageConfigSchema,
|
||||
menuConfig: menuConfigSchema,
|
||||
}).optional(),
|
||||
contractSettings: z.object({
|
||||
legalName: nullableString,
|
||||
registrationNumber: nullableString,
|
||||
taxId: nullableString,
|
||||
terms: z.string().optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
lateFeePerHour: z.number().int().nullable().optional(),
|
||||
taxRate: z.number().nullable().optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
}).optional(),
|
||||
accountingSettings: z.object({
|
||||
reportingPeriod: z.enum(['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL']).optional(),
|
||||
fiscalYearStart: z.number().int().min(1).max(12).optional(),
|
||||
currency: z.enum(['MAD', 'USD', 'EUR']).optional(),
|
||||
accountantEmail: nullableEmail,
|
||||
accountantName: nullableString,
|
||||
autoSendReport: z.boolean().optional(),
|
||||
reportFormat: z.enum(['PDF', 'CSV', 'BOTH']).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
function parseOptionalDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
return new Date(value)
|
||||
}
|
||||
|
||||
function toAuditJson<T>(value: T) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function signAdminToken(adminId: string) {
|
||||
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
|
||||
}
|
||||
@@ -97,11 +337,160 @@ router.get('/companies', requireAdminAuth, async (req, res, next) => {
|
||||
|
||||
router.get('/companies/:id', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findUniqueOrThrow({ where: { id: req.params.id }, include: { brand: true, subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } }, employees: true } })
|
||||
const company = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
res.json({ data: company })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
res.json({ data: await getMarketplaceHomepageContent() })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { homepage } = z.object({ homepage: marketplaceHomepageConfigSchema }).parse(req.body)
|
||||
const saved = await saveMarketplaceHomepageContent(homepage)
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: req.admin.id,
|
||||
action: 'UPDATE',
|
||||
resource: 'MarketplaceHomepage',
|
||||
after: toAuditJson(saved),
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
},
|
||||
})
|
||||
res.json({ data: saved })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const body = adminCompanyUpdateSchema.parse(req.body)
|
||||
const before = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: true,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (body.company) {
|
||||
await tx.company.update({
|
||||
where: { id: req.params.id },
|
||||
data: body.company,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.subscription) {
|
||||
await tx.subscription.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: {
|
||||
...body.subscription,
|
||||
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
|
||||
},
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
plan: body.subscription.plan ?? 'STARTER',
|
||||
billingPeriod: body.subscription.billingPeriod ?? 'MONTHLY',
|
||||
status: body.subscription.status ?? 'TRIALING',
|
||||
currency: body.subscription.currency ?? 'MAD',
|
||||
trialStartAt: parseOptionalDate(body.subscription.trialStartAt),
|
||||
trialEndAt: parseOptionalDate(body.subscription.trialEndAt),
|
||||
currentPeriodStart: parseOptionalDate(body.subscription.currentPeriodStart),
|
||||
currentPeriodEnd: parseOptionalDate(body.subscription.currentPeriodEnd),
|
||||
cancelledAt: parseOptionalDate(body.subscription.cancelledAt),
|
||||
cancelAtPeriodEnd: body.subscription.cancelAtPeriodEnd ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (body.brand) {
|
||||
const currentCompany = await tx.company.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
await tx.brandSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.brand,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
displayName: body.brand.displayName ?? currentCompany.name,
|
||||
subdomain: body.brand.subdomain ?? currentCompany.slug,
|
||||
paymentMethodsEnabled: before.brand?.paymentMethodsEnabled ?? [],
|
||||
...body.brand,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (body.contractSettings) {
|
||||
await tx.contractSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.contractSettings,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
...body.contractSettings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (body.accountingSettings) {
|
||||
await tx.accountingSettings.upsert({
|
||||
where: { companyId: req.params.id },
|
||||
update: body.accountingSettings,
|
||||
create: {
|
||||
companyId: req.params.id,
|
||||
...body.accountingSettings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.company.findUniqueOrThrow({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
brand: true,
|
||||
contractSettings: true,
|
||||
accountingSettings: true,
|
||||
subscription: { include: { invoices: { orderBy: { createdAt: 'desc' }, take: 10 } } },
|
||||
employees: true,
|
||||
_count: { select: { employees: true, vehicles: true, customers: true, reservations: true } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
adminUserId: req.admin.id,
|
||||
action: 'UPDATE_COMPANY',
|
||||
resource: 'Company',
|
||||
resourceId: req.params.id,
|
||||
companyId: req.params.id,
|
||||
before: toAuditJson(before),
|
||||
after: toAuditJson(body),
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
|
||||
try {
|
||||
const { status, reason } = z.object({ status: z.enum(['ACTIVE','SUSPENDED','CANCELLED']), reason: z.string().optional() }).parse(req.body)
|
||||
|
||||
@@ -37,6 +37,97 @@ const brandSchema = z.object({
|
||||
paypalEmail: z.string().email().optional(),
|
||||
paypalMerchantId: z.string().optional(),
|
||||
isListedOnMarketplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
|
||||
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
|
||||
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
|
||||
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
|
||||
pricingTitle: z.union([z.string(), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string(), z.null()]).optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
layout: z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(['hero', 'offers', 'vehicles', 'pricing']),
|
||||
x: z.number().int().min(1),
|
||||
y: z.number().int().min(1),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
})),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
menuConfig: z.object({
|
||||
aboutLabel: z.union([z.string(), z.null()]).optional(),
|
||||
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
|
||||
offersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
blogLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactLabel: z.union([z.string(), z.null()]).optional(),
|
||||
bookCarLabel: z.union([z.string(), z.null()]).optional(),
|
||||
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
showBlog: z.boolean().optional(),
|
||||
showContact: z.boolean().optional(),
|
||||
pageSections: z.object({
|
||||
home: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
offers: z.boolean().optional(),
|
||||
vehicles: z.boolean().optional(),
|
||||
pricing: z.boolean().optional(),
|
||||
}).optional(),
|
||||
about: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
highlights: z.boolean().optional(),
|
||||
details: z.boolean().optional(),
|
||||
}).optional(),
|
||||
offers: z.object({
|
||||
header: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
pricing: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
plans: z.boolean().optional(),
|
||||
payments: z.boolean().optional(),
|
||||
faq: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicles: z.object({
|
||||
header: z.boolean().optional(),
|
||||
filters: z.boolean().optional(),
|
||||
grid: z.boolean().optional(),
|
||||
}).optional(),
|
||||
vehicleDetail: z.object({
|
||||
gallery: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
}).optional(),
|
||||
blog: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
posts: z.boolean().optional(),
|
||||
}).optional(),
|
||||
contact: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
booking: z.object({
|
||||
content: z.boolean().optional(),
|
||||
}).optional(),
|
||||
bookingConfirmation: z.object({
|
||||
hero: z.boolean().optional(),
|
||||
summary: z.boolean().optional(),
|
||||
actions: z.boolean().optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
const contractSettingsSchema = z.object({
|
||||
|
||||
@@ -7,9 +7,18 @@ import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import * as paypal from '../services/paypalService'
|
||||
import { getMarketplaceHomepageContent } from '../services/platformContentService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/platform/homepage', async (_req, res, next) => {
|
||||
try {
|
||||
res.json({ data: await getMarketplaceHomepageContent() })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
function isDatabaseUnavailableError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
|
||||
|
||||
function resolveContentPath() {
|
||||
const cwd = process.cwd()
|
||||
const appScoped = path.resolve(cwd, 'src/data/platform-content.json')
|
||||
const repoScoped = path.resolve(cwd, 'apps/api/src/data/platform-content.json')
|
||||
return cwd.includes('/apps/api') ? appScoped : repoScoped
|
||||
}
|
||||
|
||||
const platformContentPath = resolveContentPath()
|
||||
|
||||
type PlatformContentFile = {
|
||||
marketplaceHomepage?: MarketplaceHomepageConfig
|
||||
}
|
||||
|
||||
async function readPlatformContentFile(): Promise<PlatformContentFile> {
|
||||
try {
|
||||
const raw = await readFile(platformContentPath, 'utf8')
|
||||
return JSON.parse(raw) as PlatformContentFile
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writePlatformContentFile(content: PlatformContentFile) {
|
||||
await mkdir(path.dirname(platformContentPath), { recursive: true })
|
||||
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
|
||||
}
|
||||
|
||||
export async function getMarketplaceHomepageContent() {
|
||||
const content = await readPlatformContentFile()
|
||||
return content.marketplaceHomepage ?? cloneMarketplaceHomepageContent()
|
||||
}
|
||||
|
||||
export async function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig) {
|
||||
const content = await readPlatformContentFile()
|
||||
await writePlatformContentFile({
|
||||
...content,
|
||||
marketplaceHomepage: homepage,
|
||||
})
|
||||
return homepage
|
||||
}
|
||||
@@ -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, l’accè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 d’essai, la configuration des paiements et les changements d’abonnement.'],
|
||||
['Accès équipe', 'Les propriétaires et collaborateurs ont des accès séparés avec rôles et invitations.'],
|
||||
['Analytique', 'Surveillez l’usage 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 à l’espace entreprise.',
|
||||
@@ -48,13 +33,6 @@ export default function SignInPage() {
|
||||
},
|
||||
ar: {
|
||||
workspace: 'مساحة الشركة',
|
||||
body: 'سجّل الدخول لإدارة الأسطول والحجوزات والفوترة ووصول الفريق والتقارير من مساحة واحدة.',
|
||||
features: [
|
||||
['الحجوزات', 'تابع الحجوزات والتسليمات والتوفر ونشاط العملاء من داخل اللوحة.'],
|
||||
['الفوترة', 'راجع الخطة وفترة التجربة وإعدادات الدفع وتغييرات الاشتراك في مكان واحد.'],
|
||||
['وصول الفريق', 'يحصل المالكون والموظفون على وصول منفصل مع أدوار ودعوات منظمة.'],
|
||||
['التحليلات', 'راقب استخدام الأسطول وحركة الإيرادات وأداء العروض.'],
|
||||
],
|
||||
access: 'الوصول إلى المساحة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
useAccount: 'استخدم حساب المالك أو الموظف للوصول إلى مساحة الشركة.',
|
||||
@@ -67,44 +45,33 @@ 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>
|
||||
</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} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
|
||||
{dict.newAccount}
|
||||
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<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>
|
||||
</main>
|
||||
</PublicShell>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="mt-8">
|
||||
{clerkFrontendEnabled ? <ConfiguredSignIn /> : <FallbackSignInCard dict={dict} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
|
||||
{dict.newAccount}
|
||||
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.createWorkspace}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 company’s own branded checkout.',
|
||||
startTrial: 'Start free trial',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
surfaceLabel: 'Marketplace surface',
|
||||
surfaceTitle: 'Designed for two audiences at once.',
|
||||
surfaceBody:
|
||||
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
|
||||
liveLabel: 'Live network',
|
||||
trustedFleets: 'Trusted fleets',
|
||||
brandedFlows: 'Branded booking flows',
|
||||
multiTenant: 'Multi-tenant operations',
|
||||
companyKicker: 'Operator workflow',
|
||||
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.',
|
||||
companyBody:
|
||||
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
|
||||
renterKicker: 'Renter experience',
|
||||
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
|
||||
renterBody:
|
||||
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.',
|
||||
pillars: [
|
||||
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
|
||||
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
|
||||
['Direct revenue path', 'Bookings move into the company’s own payment flow, so customer ownership and cash collection stay with the business.'],
|
||||
],
|
||||
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 l’essai gratuit',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
surfaceLabel: 'Surface marketplace',
|
||||
surfaceTitle: 'Pensée pour deux audiences à la fois.',
|
||||
surfaceBody:
|
||||
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
|
||||
liveLabel: 'Réseau actif',
|
||||
trustedFleets: 'Flottes fiables',
|
||||
brandedFlows: 'Parcours de marque',
|
||||
multiTenant: 'Opérations multi-tenant',
|
||||
companyKicker: 'Flux opérateur',
|
||||
companyTitle: 'Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.',
|
||||
companyBody:
|
||||
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
|
||||
renterKicker: 'Expérience client',
|
||||
renterTitle: 'Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.',
|
||||
renterBody:
|
||||
'La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
|
||||
pillars: [
|
||||
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'],
|
||||
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
|
||||
['Revenus en direct', 'Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement.'],
|
||||
],
|
||||
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 l’espace',
|
||||
},
|
||||
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¤cy=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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,19 +48,23 @@ 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">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
{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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-700">{dict.confirmation.eyebrow}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-slate-900">{dict.confirmation.title}</h1>
|
||||
<p className="mt-4 text-slate-600">
|
||||
{dict.confirmation.description}
|
||||
</p>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-700">{dict.confirmation.eyebrow}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-slate-900">{dict.confirmation.title}</h1>
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<Suspense fallback={<div className="card p-8 text-sm text-slate-500">{dict.booking.loadingForm}</div>}>
|
||||
<BookClient language={language} />
|
||||
</Suspense>
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} {companyName}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
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">
|
||||
function renderHomepageSection(type: PublicHomepageSectionType) {
|
||||
if (type === 'hero') {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,31 +62,31 @@ 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) */}
|
||||
<aside>
|
||||
<VehicleFilters
|
||||
language={language}
|
||||
categories={categories}
|
||||
transmissions={transmissions}
|
||||
maxPossiblePrice={maxPossiblePrice}
|
||||
current={{
|
||||
category: searchParams.category ?? '',
|
||||
transmission: searchParams.transmission ?? '',
|
||||
maxPrice: searchParams.maxPrice ?? '',
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
<div className={`grid gap-8 ${pageSections.vehicles.filters ? 'lg:grid-cols-[260px_1fr]' : ''}`}>
|
||||
{pageSections.vehicles.filters ? (
|
||||
<aside>
|
||||
<VehicleFilters
|
||||
language={language}
|
||||
categories={categories}
|
||||
transmissions={transmissions}
|
||||
maxPossiblePrice={maxPossiblePrice}
|
||||
current={{
|
||||
category: searchParams.category ?? '',
|
||||
transmission: searchParams.transmission ?? '',
|
||||
maxPrice: searchParams.maxPrice ?? '',
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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: 'العروض النشطة',
|
||||
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -426,6 +426,8 @@ model BrandSettings {
|
||||
paymentMethodsEnabled PaymentProvider[]
|
||||
isListedOnMarketplace Boolean @default(true)
|
||||
marketplaceRating Float?
|
||||
homePageConfig Json? @map("home_page_config")
|
||||
menuConfig Json? @map("menu_config")
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './damage'
|
||||
export * from './fuel'
|
||||
export * from './api'
|
||||
export * from './marketplace-homepage'
|
||||
export * from './public-homepage-layout'
|
||||
export * from './public-site-sections'
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
export type MarketplaceLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
export type MarketplaceHomepageMetric = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type MarketplaceHomepagePillar = {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type MarketplaceHomepageStep = {
|
||||
step: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type MarketplaceHomepageSectionType =
|
||||
| 'hero'
|
||||
| 'surface'
|
||||
| 'pillars'
|
||||
| 'audiences'
|
||||
| 'features'
|
||||
| 'steps'
|
||||
| 'closing'
|
||||
|
||||
export type MarketplaceHomepageContent = {
|
||||
sections: MarketplaceHomepageSectionType[]
|
||||
heroKicker: string
|
||||
heroTitle: string
|
||||
heroBody: string
|
||||
startTrial: string
|
||||
exploreVehicles: string
|
||||
surfaceLabel: string
|
||||
surfaceTitle: string
|
||||
surfaceBody: string
|
||||
liveLabel: string
|
||||
trustedFleets: string
|
||||
brandedFlows: string
|
||||
multiTenant: string
|
||||
companyKicker: string
|
||||
companyTitle: string
|
||||
companyBody: string
|
||||
renterKicker: string
|
||||
renterTitle: string
|
||||
renterBody: string
|
||||
pillars: MarketplaceHomepagePillar[]
|
||||
metrics: MarketplaceHomepageMetric[]
|
||||
featureLabel: string
|
||||
features: string[]
|
||||
stepsTitle: string
|
||||
steps: MarketplaceHomepageStep[]
|
||||
stepLabel: string
|
||||
readyKicker: string
|
||||
readyTitle: string
|
||||
readyBody: string
|
||||
viewPricing: string
|
||||
createWorkspace: string
|
||||
}
|
||||
|
||||
export type MarketplaceHomepageConfig = Record<MarketplaceLanguage, MarketplaceHomepageContent>
|
||||
|
||||
export const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[] = [
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
'audiences',
|
||||
'features',
|
||||
'steps',
|
||||
'closing',
|
||||
]
|
||||
|
||||
export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
|
||||
en: {
|
||||
sections: defaultMarketplaceHomepageSections,
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'Marketplace discovery with a sharper front door.',
|
||||
heroBody:
|
||||
'Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.',
|
||||
startTrial: 'Start free trial',
|
||||
exploreVehicles: 'Explore vehicles',
|
||||
surfaceLabel: 'Marketplace surface',
|
||||
surfaceTitle: 'Designed for two audiences at once.',
|
||||
surfaceBody:
|
||||
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
|
||||
liveLabel: 'Live network',
|
||||
trustedFleets: 'Trusted fleets',
|
||||
brandedFlows: 'Branded booking flows',
|
||||
multiTenant: 'Multi-tenant operations',
|
||||
companyKicker: 'Operator workflow',
|
||||
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.',
|
||||
companyBody:
|
||||
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
|
||||
renterKicker: 'Renter experience',
|
||||
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
|
||||
renterBody:
|
||||
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.',
|
||||
pillars: [
|
||||
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
|
||||
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
|
||||
['Direct revenue path', 'Bookings move into the company’s own payment flow, so customer ownership and cash collection stay with the business.'],
|
||||
].map(([title, body]) => ({ title, body })),
|
||||
metrics: [
|
||||
['01', 'Shared marketplace visibility'],
|
||||
['02', 'Direct company checkout'],
|
||||
['03', 'Company-owned payments'],
|
||||
].map(([value, label]) => ({ value, label })),
|
||||
featureLabel: 'What the product covers',
|
||||
features: [
|
||||
'Fleet management with multi-photo uploads',
|
||||
'Marketplace offers and redirect booking flow',
|
||||
'Branded public booking site per company',
|
||||
'Customer CRM, analytics, and billing controls',
|
||||
],
|
||||
stepsTitle: 'How companies launch',
|
||||
steps: [
|
||||
['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'],
|
||||
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
|
||||
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
|
||||
].map(([step, title, body]) => ({ step, title, body })),
|
||||
stepLabel: 'Step',
|
||||
readyKicker: 'Ready to launch',
|
||||
readyTitle: 'A marketplace homepage should sell the system in seconds.',
|
||||
readyBody:
|
||||
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.',
|
||||
viewPricing: 'View pricing',
|
||||
createWorkspace: 'Create workspace',
|
||||
},
|
||||
fr: {
|
||||
sections: defaultMarketplaceHomepageSections,
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'Une vitrine marketplace plus nette et plus forte.',
|
||||
heroBody:
|
||||
'Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune, et chaque réservation se termine sur le paiement de marque de la société.',
|
||||
startTrial: 'Commencer l’essai gratuit',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
surfaceLabel: 'Surface marketplace',
|
||||
surfaceTitle: 'Pensée pour deux audiences à la fois.',
|
||||
surfaceBody:
|
||||
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.',
|
||||
liveLabel: 'Réseau actif',
|
||||
trustedFleets: 'Flottes fiables',
|
||||
brandedFlows: 'Parcours de marque',
|
||||
multiTenant: 'Opérations multi-tenant',
|
||||
companyKicker: 'Flux opérateur',
|
||||
companyTitle: 'Pilotez inventaire, offres, facturation et équipe depuis une seule couche de commande.',
|
||||
companyBody:
|
||||
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.',
|
||||
renterKicker: 'Expérience client',
|
||||
renterTitle: 'Laissez les clients comparer rapidement puis dirigez-les vers le bon site entreprise.',
|
||||
renterBody:
|
||||
'La marketplace agit comme moteur de découverte, pas comme agrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.',
|
||||
pillars: [
|
||||
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux admin vers la découverte marketplace et les pages de réservation de marque.'],
|
||||
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
|
||||
['Revenus en direct', 'Les réservations basculent vers le paiement propre à l’entreprise, pour garder la relation client et l’encaissement.'],
|
||||
].map(([title, body]) => ({ title, body })),
|
||||
metrics: [
|
||||
['01', 'Visibilité marketplace partagée'],
|
||||
['02', 'Paiement direct entreprise'],
|
||||
['03', 'Paiements détenus par la société'],
|
||||
].map(([value, label]) => ({ value, label })),
|
||||
featureLabel: 'Ce que couvre le produit',
|
||||
features: [
|
||||
'Gestion de flotte avec téléversement multi-photos',
|
||||
'Offres marketplace et redirection vers la réservation',
|
||||
'Site public de réservation par entreprise',
|
||||
'CRM client, analytics et contrôle de facturation',
|
||||
],
|
||||
stepsTitle: 'Comment les entreprises démarrent',
|
||||
steps: [
|
||||
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'],
|
||||
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'],
|
||||
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'],
|
||||
].map(([step, title, body]) => ({ step, title, body })),
|
||||
stepLabel: 'Étape',
|
||||
readyKicker: 'Prêt à démarrer',
|
||||
readyTitle: 'Une homepage marketplace doit vendre le système en quelques secondes.',
|
||||
readyBody:
|
||||
'Utilisez la marketplace pour capter la demande, puis faites passer les clients vers une expérience de marque qui garde prix, paiements et confiance liés à votre entreprise.',
|
||||
viewPricing: 'Voir les tarifs',
|
||||
createWorkspace: 'Créer l’espace',
|
||||
},
|
||||
ar: {
|
||||
sections: defaultMarketplaceHomepageSections,
|
||||
heroKicker: 'RentalDriveGo',
|
||||
heroTitle: 'واجهة سوق أوضح وأقوى.',
|
||||
heroBody:
|
||||
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة نفسها.',
|
||||
startTrial: 'ابدأ التجربة المجانية',
|
||||
exploreVehicles: 'استكشف السيارات',
|
||||
surfaceLabel: 'واجهة السوق',
|
||||
surfaceTitle: 'مصممة لجمهورين في الوقت نفسه.',
|
||||
surfaceBody:
|
||||
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
|
||||
liveLabel: 'شبكة نشطة',
|
||||
trustedFleets: 'أساطيل موثوقة',
|
||||
brandedFlows: 'مسارات حجز مخصصة',
|
||||
multiTenant: 'عمليات متعددة الشركات',
|
||||
companyKicker: 'تدفق المشغل',
|
||||
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
|
||||
companyBody:
|
||||
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
|
||||
renterKicker: 'تجربة المستأجر',
|
||||
renterTitle: 'دع المستأجر يقارن بسرعة ثم انقله إلى موقع الشركة المناسب.',
|
||||
renterBody:
|
||||
'السوق هنا محرك اكتشاف وليس مجمعاً بلا نهاية. البحث هنا، الحجز هناك، والدفع مباشرة للشركة.',
|
||||
pillars: [
|
||||
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من تدفق إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
|
||||
['اكتشاف مؤهل', 'العروض المميزة والتصفح حسب الموقع وإشارات السمعة تساعد المستأجرين على تضييق الخيارات بسرعة.'],
|
||||
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى الملكية المالية وعلاقة العميل مع النشاط نفسه.'],
|
||||
].map(([title, body]) => ({ title, body })),
|
||||
metrics: [
|
||||
['01', 'ظهور مشترك في السوق'],
|
||||
['02', 'دفع مباشر للشركة'],
|
||||
['03', 'مدفوعات مملوكة للشركة'],
|
||||
].map(([value, label]) => ({ value, label })),
|
||||
featureLabel: 'ما الذي يغطيه المنتج',
|
||||
features: [
|
||||
'إدارة الأسطول مع رفع عدة صور',
|
||||
'عروض السوق والتحويل إلى مسار الحجز',
|
||||
'موقع حجز عام مخصص لكل شركة',
|
||||
'إدارة العملاء والتحليلات والفوترة',
|
||||
],
|
||||
stepsTitle: 'كيف تبدأ الشركات',
|
||||
steps: [
|
||||
['1', 'أنشئ مساحة شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم تحقق من حساب المالك.'],
|
||||
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
|
||||
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
|
||||
].map(([step, title, body]) => ({ step, title, body })),
|
||||
stepLabel: 'الخطوة',
|
||||
readyKicker: 'جاهز للانطلاق',
|
||||
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
|
||||
readyBody:
|
||||
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك وتحافظ على التسعير والمدفوعات والثقة داخل نشاطك.',
|
||||
viewPricing: 'عرض الأسعار',
|
||||
createWorkspace: 'إنشاء المساحة',
|
||||
},
|
||||
}
|
||||
|
||||
export function cloneMarketplaceHomepageContent(): MarketplaceHomepageConfig {
|
||||
return JSON.parse(JSON.stringify(defaultMarketplaceHomepageContent)) as MarketplaceHomepageConfig
|
||||
}
|
||||
|
||||
export function resolveMarketplaceHomepageSections(
|
||||
sections?: MarketplaceHomepageSectionType[] | null,
|
||||
): MarketplaceHomepageSectionType[] {
|
||||
const source = sections?.length ? sections : defaultMarketplaceHomepageSections
|
||||
return source.filter((section, index) => source.indexOf(section) === index)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export const PUBLIC_HOMEPAGE_GRID_COLUMNS = 12
|
||||
export const PUBLIC_HOMEPAGE_GRID_ROWS = 12
|
||||
|
||||
export type PublicHomepageSectionType = 'hero' | 'offers' | 'vehicles' | 'pricing'
|
||||
|
||||
export type PublicHomepageLayoutItem = {
|
||||
id: string
|
||||
type: PublicHomepageSectionType
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export type PublicHomepageLayout = {
|
||||
items: PublicHomepageLayoutItem[]
|
||||
}
|
||||
|
||||
export const defaultPublicHomepageLayout: PublicHomepageLayout = {
|
||||
items: [
|
||||
{ id: 'hero', type: 'hero', x: 1, y: 1, w: 7, h: 5 },
|
||||
{ id: 'offers', type: 'offers', x: 8, y: 1, w: 5, h: 3 },
|
||||
{ id: 'vehicles', type: 'vehicles', x: 1, y: 6, w: 7, h: 4 },
|
||||
{ id: 'pricing', type: 'pricing', x: 8, y: 4, w: 5, h: 6 },
|
||||
],
|
||||
}
|
||||
|
||||
const SECTION_LIMITS: Record<PublicHomepageSectionType, { minW: number; minH: number; maxW: number; maxH: number }> = {
|
||||
hero: { minW: 4, minH: 4, maxW: 12, maxH: 8 },
|
||||
offers: { minW: 3, minH: 2, maxW: 12, maxH: 8 },
|
||||
vehicles: { minW: 4, minH: 3, maxW: 12, maxH: 8 },
|
||||
pricing: { minW: 4, minH: 4, maxW: 12, maxH: 9 },
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function clonePublicHomepageLayout(): PublicHomepageLayout {
|
||||
return JSON.parse(JSON.stringify(defaultPublicHomepageLayout)) as PublicHomepageLayout
|
||||
}
|
||||
|
||||
export function getPublicHomepageSectionLimits(type: PublicHomepageSectionType) {
|
||||
return SECTION_LIMITS[type]
|
||||
}
|
||||
|
||||
export function normalizePublicHomepageLayoutItem(item: PublicHomepageLayoutItem): PublicHomepageLayoutItem {
|
||||
const limits = SECTION_LIMITS[item.type]
|
||||
const w = clamp(Math.round(item.w), limits.minW, limits.maxW)
|
||||
const h = clamp(Math.round(item.h), limits.minH, limits.maxH)
|
||||
const x = clamp(Math.round(item.x), 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - w + 1)
|
||||
const y = clamp(Math.round(item.y), 1, PUBLIC_HOMEPAGE_GRID_ROWS - h + 1)
|
||||
return {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePublicHomepageLayout(
|
||||
layout?: Partial<PublicHomepageLayout> | null,
|
||||
visibleTypes?: PublicHomepageSectionType[],
|
||||
): PublicHomepageLayout {
|
||||
const defaultsByType = new Map(defaultPublicHomepageLayout.items.map((item) => [item.type, item] as const))
|
||||
const allowedTypes = new Set<PublicHomepageSectionType>(visibleTypes ?? (['hero', 'offers', 'vehicles', 'pricing'] as PublicHomepageSectionType[]))
|
||||
const items = (layout?.items ?? [])
|
||||
.filter((item): item is PublicHomepageLayoutItem => {
|
||||
if (!item || typeof item !== 'object') return false
|
||||
if (!('type' in item) || !allowedTypes.has(item.type as PublicHomepageSectionType)) return false
|
||||
return ['id', 'x', 'y', 'w', 'h'].every((key) => key in item)
|
||||
})
|
||||
.map((item) => normalizePublicHomepageLayoutItem(item))
|
||||
|
||||
const seen = new Set(items.map((item) => item.type))
|
||||
for (const type of allowedTypes) {
|
||||
if (seen.has(type)) continue
|
||||
const fallback = defaultsByType.get(type)
|
||||
if (fallback) items.push(fallback)
|
||||
}
|
||||
|
||||
return {
|
||||
items: items.sort((a, b) => (a.y - b.y) || (a.x - b.x)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
export type PublicSitePageSections = {
|
||||
home: {
|
||||
hero: boolean
|
||||
offers: boolean
|
||||
vehicles: boolean
|
||||
pricing: boolean
|
||||
}
|
||||
about: {
|
||||
hero: boolean
|
||||
highlights: boolean
|
||||
details: boolean
|
||||
}
|
||||
offers: {
|
||||
header: boolean
|
||||
grid: boolean
|
||||
}
|
||||
pricing: {
|
||||
hero: boolean
|
||||
plans: boolean
|
||||
payments: boolean
|
||||
faq: boolean
|
||||
}
|
||||
vehicles: {
|
||||
header: boolean
|
||||
filters: boolean
|
||||
grid: boolean
|
||||
}
|
||||
vehicleDetail: {
|
||||
gallery: boolean
|
||||
summary: boolean
|
||||
}
|
||||
blog: {
|
||||
hero: boolean
|
||||
posts: boolean
|
||||
}
|
||||
contact: {
|
||||
content: boolean
|
||||
}
|
||||
booking: {
|
||||
content: boolean
|
||||
}
|
||||
bookingConfirmation: {
|
||||
hero: boolean
|
||||
summary: boolean
|
||||
actions: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultPublicSitePageSections: PublicSitePageSections = {
|
||||
home: {
|
||||
hero: true,
|
||||
offers: true,
|
||||
vehicles: true,
|
||||
pricing: true,
|
||||
},
|
||||
about: {
|
||||
hero: true,
|
||||
highlights: true,
|
||||
details: true,
|
||||
},
|
||||
offers: {
|
||||
header: true,
|
||||
grid: true,
|
||||
},
|
||||
pricing: {
|
||||
hero: true,
|
||||
plans: true,
|
||||
payments: true,
|
||||
faq: true,
|
||||
},
|
||||
vehicles: {
|
||||
header: true,
|
||||
filters: true,
|
||||
grid: true,
|
||||
},
|
||||
vehicleDetail: {
|
||||
gallery: true,
|
||||
summary: true,
|
||||
},
|
||||
blog: {
|
||||
hero: true,
|
||||
posts: true,
|
||||
},
|
||||
contact: {
|
||||
content: true,
|
||||
},
|
||||
booking: {
|
||||
content: true,
|
||||
},
|
||||
bookingConfirmation: {
|
||||
hero: true,
|
||||
summary: true,
|
||||
actions: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function clonePublicSitePageSections(): PublicSitePageSections {
|
||||
return JSON.parse(JSON.stringify(defaultPublicSitePageSections)) as PublicSitePageSections
|
||||
}
|
||||
Reference in New Issue
Block a user