add homepage management

This commit is contained in:
root
2026-05-07 00:34:29 -04:00
parent 750ae56a29
commit c4a45c8b21
33 changed files with 4228 additions and 418 deletions
@@ -0,0 +1,231 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
PUBLIC_HOMEPAGE_GRID_COLUMNS,
PUBLIC_HOMEPAGE_GRID_ROWS,
getPublicHomepageSectionLimits,
type PublicHomepageLayout,
type PublicHomepageLayoutItem,
type PublicHomepageSectionType,
} from '@rentaldrivego/types'
type Props = {
layout: PublicHomepageLayout
availableSections: PublicHomepageSectionType[]
onChange: (layout: PublicHomepageLayout) => void
onAddSection: (type: PublicHomepageSectionType) => void
onRemoveSection: (type: PublicHomepageSectionType) => void
}
type Interaction = {
itemId: string
mode: 'move' | 'resize'
startX: number
startY: number
origin: PublicHomepageLayoutItem
}
const ROW_HEIGHT = 52
const SECTION_META: Record<PublicHomepageSectionType, { label: string; tint: string; description: string }> = {
hero: { label: 'Hero', tint: 'from-sky-500 to-cyan-400', description: 'Headline, CTA buttons, and brand media.' },
offers: { label: 'Offers', tint: 'from-emerald-500 to-lime-400', description: 'Promotions and campaign cards.' },
vehicles: { label: 'Vehicles', tint: 'from-amber-500 to-orange-400', description: 'Published fleet grid.' },
pricing: { label: 'Pricing', tint: 'from-fuchsia-500 to-rose-400', description: 'Plans and pricing content.' },
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
export function HomepageLayoutEditor({ layout, availableSections, onChange, onAddSection, onRemoveSection }: Props) {
const canvasRef = useRef<HTMLDivElement | null>(null)
const [interaction, setInteraction] = useState<Interaction | null>(null)
useEffect(() => {
if (!interaction) return
function handlePointerMove(event: PointerEvent) {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS
const deltaCols = Math.round((event.clientX - interaction.startX) / colWidth)
const deltaRows = Math.round((event.clientY - interaction.startY) / ROW_HEIGHT)
const limits = getPublicHomepageSectionLimits(interaction.origin.type)
onChange({
items: layout.items.map((item) => {
if (item.id !== interaction.itemId) return item
if (interaction.mode === 'move') {
return {
...item,
x: clamp(interaction.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.w + 1),
y: clamp(interaction.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.h + 1),
}
}
const nextW = clamp(
interaction.origin.w + deltaCols,
limits.minW,
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.x + 1),
)
const nextH = clamp(
interaction.origin.h + deltaRows,
limits.minH,
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.y + 1),
)
return {
...item,
w: nextW,
h: nextH,
}
}),
})
}
function handlePointerUp() {
setInteraction(null)
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [interaction, layout.items, onChange])
return (
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4">
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h4 className="text-sm font-semibold text-zinc-100">Homepage layout</h4>
<p className="mt-1 text-xs text-zinc-500">Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.</p>
</div>
<div className="flex flex-wrap gap-2">
{availableSections.map((type) => (
<button
key={type}
type="button"
onClick={() => onAddSection(type)}
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300 transition hover:bg-emerald-500/20"
>
Add {SECTION_META[type].label}
</button>
))}
</div>
</div>
<div
ref={canvasRef}
className="relative mt-4 hidden overflow-hidden rounded-[1.5rem] border border-zinc-800 bg-zinc-950 lg:block"
style={{
height: ROW_HEIGHT * PUBLIC_HOMEPAGE_GRID_ROWS,
backgroundImage: `
linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)
`,
backgroundSize: `${100 / PUBLIC_HOMEPAGE_GRID_COLUMNS}% ${ROW_HEIGHT}px`,
}}
>
{layout.items.map((item) => {
const meta = SECTION_META[item.type]
return (
<div
key={item.id}
className="absolute overflow-hidden rounded-[1.25rem] border border-white/10 bg-zinc-900/95 shadow-xl shadow-black/30"
style={{
left: `${((item.x - 1) / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
top: `${(item.y - 1) * ROW_HEIGHT}px`,
width: `${(item.w / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
height: `${item.h * ROW_HEIGHT}px`,
}}
>
<button
type="button"
onPointerDown={(event) => {
event.preventDefault()
setInteraction({
itemId: item.id,
mode: 'move',
startX: event.clientX,
startY: event.clientY,
origin: item,
})
}}
className={`flex w-full cursor-grab items-start justify-between bg-gradient-to-r ${meta.tint} px-4 py-3 text-left active:cursor-grabbing`}
>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/75">Drag</p>
<p className="mt-1 text-sm font-black text-white">{meta.label}</p>
</div>
<span className="rounded-full bg-black/20 px-2 py-1 text-[11px] font-semibold text-white">
{item.w}x{item.h}
</span>
</button>
<div className="flex h-[calc(100%-60px)] flex-col justify-between p-4">
<p className="max-w-xs text-sm leading-6 text-zinc-300">{meta.description}</p>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-zinc-500">x{item.x} y{item.y}</span>
<button
type="button"
onClick={() => onRemoveSection(item.type)}
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20"
>
Remove
</button>
</div>
</div>
<button
type="button"
aria-label={`Resize ${meta.label}`}
onPointerDown={(event) => {
event.preventDefault()
setInteraction({
itemId: item.id,
mode: 'resize',
startX: event.clientX,
startY: event.clientY,
origin: item,
})
}}
className="absolute bottom-2 right-2 h-7 w-7 rounded-full border border-white/15 bg-white/10 text-white"
>
<span className="block rotate-45 text-sm">+</span>
</button>
</div>
)
})}
</div>
<div className="mt-4 grid gap-3 lg:hidden">
{layout.items.map((item) => {
const meta = SECTION_META[item.type]
return (
<div key={item.id} className="rounded-2xl border border-zinc-800 bg-zinc-900/70 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-zinc-100">{meta.label}</p>
<p className="mt-1 text-xs text-zinc-500">{meta.description}</p>
</div>
<button
type="button"
onClick={() => onRemoveSection(item.type)}
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300"
>
Remove
</button>
</div>
<p className="mt-3 text-xs text-zinc-500">Desktop drag canvas is available on larger screens.</p>
</div>
)
})}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -110,7 +110,7 @@ export default function AdminCompaniesPage() {
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
method: 'POST',
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ status }),
})
+1
View File
@@ -8,6 +8,7 @@ import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
const navLinks = [
{ href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/dashboard/companies', key: 'companies', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
{ href: '/dashboard/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
@@ -0,0 +1,801 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
cloneMarketplaceHomepageContent,
resolveMarketplaceHomepageSections,
type MarketplaceHomepageConfig,
type MarketplaceHomepageContent,
type MarketplaceHomepageSectionType,
type MarketplaceLanguage,
} from '@rentaldrivego/types'
import { useAdminI18n } from '@/components/I18nProvider'
const API_BASE = '/api/v1'
interface Company {
id: string
name: string
slug: string
status: string
email: string
brand: {
displayName: string | null
} | null
}
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
TRIALING: 'text-sky-400 bg-sky-900/30',
SUSPENDED: 'text-red-400 bg-red-900/30',
PENDING: 'text-amber-400 bg-amber-900/30',
CANCELLED: 'text-zinc-400 bg-zinc-800',
}
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500'
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
})
return cloned
}
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
'hero',
'surface',
'pillars',
'audiences',
'features',
'steps',
'closing',
]
export default function AdminSiteConfigPage() {
const { language, dict } = useAdminI18n()
const copy = {
en: {
title: 'Site configuration',
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
homepageTitle: 'Main website homepage',
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
saveHomepage: 'Save homepage',
savingHomepage: 'Saving…',
homepageSaved: 'Marketplace homepage saved.',
search: 'Search by company or slug…',
loading: 'Loading…',
empty: 'No companies found',
company: 'Company',
slug: 'Slug',
status: 'Status',
actions: 'Actions',
configure: 'Configure site',
manageCompany: 'Open company',
companiesTitle: 'Company branded sites',
languageLabel: 'Language',
hero: 'Hero',
surface: 'Surface',
companySection: 'Operator card',
renterSection: 'Renter card',
valueSection: 'Value blocks',
featureSection: 'Feature list',
stepsSection: 'Launch steps',
closingSection: 'Closing call to action',
previewTitle: 'Live preview',
previewDescription: 'Draft changes appear here before you save them.',
homepageSections: 'Homepage sections',
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
addSection: 'Add',
removeSection: 'Remove',
expand: 'Expand',
collapse: 'Collapse',
},
fr: {
title: 'Configuration du site',
description: 'Modifiez ici la homepage principale de la marketplace, ou ouvrez une entreprise pour gérer sa homepage publique et son menu.',
homepageTitle: 'Homepage du site principal',
homepageDescription: 'Cette section contrôle la homepage marketplace affichée sur le site principal RentalDriveGo.',
saveHomepage: 'Enregistrer la homepage',
savingHomepage: 'Enregistrement…',
homepageSaved: 'Homepage marketplace enregistrée.',
search: 'Rechercher par entreprise ou slug…',
loading: 'Chargement…',
empty: 'Aucune entreprise trouvée',
company: 'Entreprise',
slug: 'Slug',
status: 'Statut',
actions: 'Actions',
configure: 'Configurer le site',
manageCompany: 'Ouvrir lentreprise',
companiesTitle: 'Sites de marque des entreprises',
languageLabel: 'Langue',
hero: 'Hero',
surface: 'Bloc marketplace',
companySection: 'Bloc opérateur',
renterSection: 'Bloc client',
valueSection: 'Blocs de valeur',
featureSection: 'Liste des fonctionnalités',
stepsSection: 'Étapes de lancement',
closingSection: 'Appel à laction final',
previewTitle: 'Aperçu en direct',
previewDescription: 'Les brouillons apparaissent ici avant lenregistrement.',
homepageSections: 'Sections de la homepage',
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la homepage marketplace principale.',
addSection: 'Ajouter',
removeSection: 'Retirer',
expand: 'Ouvrir',
collapse: 'Réduire',
},
ar: {
title: 'إعدادات الموقع',
description: 'عدّل الصفحة الرئيسية للموقع الرئيسي هنا، أو افتح شركة لإدارة صفحتها العامة وقائمة التنقل الخاصة بها.',
homepageTitle: 'الصفحة الرئيسية للموقع الرئيسي',
homepageDescription: 'هذا القسم يتحكم في الصفحة الرئيسية للمنصة على موقع RentalDriveGo الرئيسي.',
saveHomepage: 'حفظ الصفحة الرئيسية',
savingHomepage: 'جارٍ الحفظ…',
homepageSaved: 'تم حفظ الصفحة الرئيسية للمنصة.',
search: 'ابحث باسم الشركة أو slug…',
loading: 'جارٍ التحميل…',
empty: 'لم يتم العثور على شركات',
company: 'الشركة',
slug: 'Slug',
status: 'الحالة',
actions: 'الإجراءات',
configure: 'إعداد الموقع',
manageCompany: 'فتح الشركة',
companiesTitle: 'المواقع العامة للشركات',
languageLabel: 'اللغة',
hero: 'البطاقة الرئيسية',
surface: 'قسم المنصة',
companySection: 'بطاقة المشغل',
renterSection: 'بطاقة المستأجر',
valueSection: 'عناصر القيمة',
featureSection: 'قائمة الميزات',
stepsSection: 'خطوات الإطلاق',
closingSection: 'الدعوة الختامية',
previewTitle: 'معاينة مباشرة',
previewDescription: 'تظهر التغييرات هنا قبل الحفظ.',
homepageSections: 'أقسام الصفحة الرئيسية',
homepageSectionsDescription: 'أضف أو احذف كتل الصفحة الرئيسية للمنصة.',
addSection: 'إضافة',
removeSection: 'إزالة',
expand: 'توسيع',
collapse: 'طي',
},
}[language]
const [companies, setCompanies] = useState<Company[]>([])
const [filtered, setFiltered] = useState<Company[]>([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
const [homepageSaving, setHomepageSaving] = useState(false)
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
const [homepageExpanded, setHomepageExpanded] = useState(false)
useEffect(() => {
setHomepageLanguage(language)
}, [language])
useEffect(() => {
async function fetchData() {
const token = localStorage.getItem('admin_token')
try {
const [companiesRes, homepageRes] = await Promise.all([
fetch(`${API_BASE}/admin/companies`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
}),
fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
cache: 'no-store',
}),
])
const companiesJson = await companiesRes.json()
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
setCompanies(companiesJson.data ?? [])
setFiltered(companiesJson.data ?? [])
const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) {
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
useEffect(() => {
const q = search.toLowerCase()
setFiltered(
companies.filter((company) =>
company.name.toLowerCase().includes(q)
|| company.slug.toLowerCase().includes(q)
|| company.email.toLowerCase().includes(q),
),
)
}, [search, companies])
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
setHomepage((current) => ({
...current,
[homepageLanguage]: {
...current[homepageLanguage],
...patch,
},
}))
}
function updateMetric(index: number, label: string) {
const metrics = homepage[homepageLanguage].metrics.map((metric, metricIndex) => (
metricIndex === index ? { ...metric, label } : metric
))
updateHomepageContent({ metrics })
}
function updatePillar(index: number, patch: { title?: string; body?: string }) {
const pillars = homepage[homepageLanguage].pillars.map((pillar, pillarIndex) => (
pillarIndex === index ? { ...pillar, ...patch } : pillar
))
updateHomepageContent({ pillars })
}
function updateStep(index: number, patch: { title?: string; body?: string }) {
const steps = homepage[homepageLanguage].steps.map((step, stepIndex) => (
stepIndex === index ? { ...step, ...patch } : step
))
updateHomepageContent({ steps })
}
function getActiveSections() {
return resolveMarketplaceHomepageSections(activeContent.sections)
}
function addHomepageSection(section: MarketplaceHomepageSectionType) {
const sections = getActiveSections()
if (sections.includes(section)) return
updateHomepageContent({ sections: [...sections, section] })
}
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
const sections = getActiveSections().filter((item) => item !== section)
if (sections.length === 0) return
updateHomepageContent({ sections })
}
async function saveHomepage() {
setHomepageSaving(true)
setHomepageMessage(null)
const token = localStorage.getItem('admin_token')
try {
const res = await fetch(`${API_BASE}/admin/site-config/marketplace-homepage`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ homepage }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
setHomepageMessage(copy.homepageSaved)
} catch (err: any) {
setError(err.message)
} finally {
setHomepageSaving(false)
}
}
const activeContent = homepage[homepageLanguage]
const activeSections = getActiveSections()
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
hero: copy.hero,
surface: copy.surface,
pillars: copy.valueSection,
audiences: `${copy.companySection} / ${copy.renterSection}`,
features: copy.featureSection,
steps: copy.stepsSection,
closing: copy.closingSection,
}
return (
<div className="shell space-y-6 py-8">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
<p className="mt-2 max-w-3xl text-sm text-zinc-500">{copy.description}</p>
</div>
<input
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 xl:w-72"
placeholder={copy.search}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
{error ? <div className="panel p-4 text-sm text-red-400">{error}</div> : null}
<section className="panel p-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h2 className="text-base font-semibold text-zinc-100">{copy.homepageTitle}</h2>
<p className="mt-1 text-sm text-zinc-500">{copy.homepageDescription}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
<button
key={value}
type="button"
onClick={() => setHomepageLanguage(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
homepageLanguage === value ? 'bg-emerald-500 text-white' : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
}`}
>
{value.toUpperCase()}
</button>
))}
</div>
<button
type="button"
onClick={() => setHomepageExpanded((current) => !current)}
className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200 transition hover:border-zinc-600 hover:bg-zinc-800"
>
{homepageExpanded ? copy.collapse : copy.expand}
</button>
</div>
</div>
{homepageExpanded ? (
<>
{homepageMessage ? <div className="mt-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">{homepageMessage}</div> : null}
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/60 p-4">
<h3 className="text-sm font-semibold text-zinc-100">{copy.homepageSections}</h3>
<p className="mt-1 text-xs text-zinc-500">{copy.homepageSectionsDescription}</p>
<div className="mt-4 flex flex-wrap gap-2">
{activeSections.map((section) => (
<div key={section} className="inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200">
<span>{sectionLabels[section]}</span>
<button
type="button"
onClick={() => removeHomepageSection(section)}
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-rose-300"
>
{copy.removeSection}
</button>
</div>
))}
</div>
{availableSections.length ? (
<div className="mt-4 flex flex-wrap gap-2">
{availableSections.map((section) => (
<button
key={section}
type="button"
onClick={() => addHomepageSection(section)}
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-emerald-500/20"
>
{copy.addSection} {sectionLabels[section]}
</button>
))}
</div>
) : null}
</div>
<div className="mt-6 rounded-[1.75rem] border border-zinc-800 bg-zinc-950/60 p-4">
<div className="flex items-center justify-between gap-4 border-b border-zinc-800 pb-4">
<div>
<h3 className="text-sm font-semibold text-zinc-100">{copy.previewTitle}</h3>
<p className="mt-1 text-xs text-zinc-500">{copy.previewDescription}</p>
</div>
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-300">
{homepageLanguage.toUpperCase()}
</span>
</div>
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-stone-200 bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] shadow-2xl shadow-black/20">
<div className="border-b border-stone-200 bg-white/90 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.32em] text-amber-700">{activeContent.heroKicker}</p>
<p className="mt-2 text-sm text-stone-500">rentaldrivego.com</p>
</div>
<div className="flex flex-wrap gap-2 text-xs font-semibold text-stone-600">
{activeContent.metrics.map((metric) => (
<span key={metric.value} className="rounded-full border border-stone-300 bg-white px-3 py-1.5">
{metric.label}
</span>
))}
</div>
</div>
</div>
<div className="space-y-10 px-5 py-6 lg:px-8">
{activeSections.includes('hero') ? <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<div>
<h4 className="text-4xl font-black tracking-[-0.04em] text-stone-950">{activeContent.heroTitle}</h4>
<p className="mt-5 max-w-2xl text-base leading-8 text-stone-600">{activeContent.heroBody}</p>
<div className="mt-8 flex flex-wrap gap-3">
<span className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
<span className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-700">{activeContent.exploreVehicles}</span>
</div>
</div>
{activeSections.includes('surface') ? (
<div className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
{activeContent.surfaceLabel}
</span>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
{activeContent.liveLabel}
</span>
</div>
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
<div className="mt-6 space-y-3">
{[activeContent.trustedFleets, activeContent.brandedFlows, activeContent.multiTenant].map((item, index) => (
<div key={`${item}-${index}`} className="rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-3 text-sm font-semibold">
{item}
</div>
))}
</div>
</div>
) : null}
</section> : null}
{activeSections.includes('surface') && !activeSections.includes('hero') ? (
<section className="rounded-[1.5rem] bg-stone-950 p-6 text-white">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200">
{activeContent.surfaceLabel}
</span>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
{activeContent.liveLabel}
</span>
</div>
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
</section>
) : null}
{activeSections.includes('pillars') ? <section className="grid gap-4 lg:grid-cols-3">
{activeContent.pillars.map((pillar, index) => (
<article
key={`${pillar.title}-${index}`}
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-stone-900 bg-stone-950 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
>
<p className={`text-xs font-bold uppercase tracking-[0.24em] ${index === 1 ? 'text-stone-300' : 'text-stone-400'}`}>0{index + 1}</p>
<h5 className="mt-3 text-xl font-black">{pillar.title}</h5>
<p className={`mt-3 text-sm leading-7 ${index === 1 ? 'text-stone-200' : 'text-stone-600'}`}>{pillar.body}</p>
</article>
))}
</section> : null}
{activeSections.includes('audiences') ? <section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.companyKicker}</p>
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.companyTitle}</h5>
<p className="mt-3 text-sm leading-7 text-stone-600">{activeContent.companyBody}</p>
</article>
<article className="rounded-[1.5rem] border border-stone-200 bg-[#f7efe2] p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.renterKicker}</p>
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.renterTitle}</h5>
<p className="mt-3 text-sm leading-7 text-stone-700">{activeContent.renterBody}</p>
</article>
</section> : null}
{(activeSections.includes('features') || activeSections.includes('steps')) ? <section className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
{activeSections.includes('features') ? (
<article className="rounded-[1.5rem] border border-stone-200 bg-[#fffdf8] p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.featureLabel}</p>
<div className="mt-5 space-y-3">
{activeContent.features.map((feature, index) => (
<div key={`${feature}-${index}`} className="flex items-start gap-3 rounded-[1rem] border border-stone-200 bg-white px-4 py-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-stone-950 text-xs font-bold text-white">
{index + 1}
</span>
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
</div>
))}
</div>
</article>
) : <div />}
{activeSections.includes('steps') ? (
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-amber-700">{activeContent.readyKicker}</p>
<h5 className="mt-3 text-2xl font-black text-stone-950">{activeContent.stepsTitle}</h5>
<div className="mt-5 space-y-3">
{activeContent.steps.map((step) => (
<div key={step.step} className="rounded-[1rem] border border-stone-200 bg-stone-50 p-4">
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.stepLabel} {step.step}</p>
<h6 className="mt-2 text-lg font-black text-stone-950">{step.title}</h6>
<p className="mt-2 text-sm leading-7 text-stone-600">{step.body}</p>
</div>
))}
</div>
</article>
) : <div />}
</section> : null}
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-stone-950 px-6 py-7 text-white">
<p className="text-xs font-bold uppercase tracking-[0.28em] text-amber-300">{activeContent.readyKicker}</p>
<h5 className="mt-4 max-w-3xl text-3xl font-black tracking-[-0.04em]">{activeContent.readyTitle}</h5>
<p className="mt-4 max-w-2xl text-sm leading-8 text-stone-300">{activeContent.readyBody}</p>
<div className="mt-6 flex flex-wrap gap-3">
<span className="rounded-full border border-white/20 px-5 py-3 text-sm font-semibold">{activeContent.viewPricing}</span>
<span className="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-stone-950">{activeContent.createWorkspace}</span>
</div>
</section> : null}
</div>
</div>
</div>
<div className="mt-6 space-y-8">
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Brand kicker</span>
<input className={INPUT_CLASS} value={activeContent.heroKicker} onChange={(event) => updateHomepageContent({ heroKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Primary CTA</span>
<input className={INPUT_CLASS} value={activeContent.startTrial} onChange={(event) => updateHomepageContent({ startTrial: event.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.hero} title</span>
<input className={INPUT_CLASS} value={activeContent.heroTitle} onChange={(event) => updateHomepageContent({ heroTitle: event.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.hero} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.heroBody} onChange={(event) => updateHomepageContent({ heroBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Secondary CTA</span>
<input className={INPUT_CLASS} value={activeContent.exploreVehicles} onChange={(event) => updateHomepageContent({ exploreVehicles: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.surface} badge</span>
<input className={INPUT_CLASS} value={activeContent.surfaceLabel} onChange={(event) => updateHomepageContent({ surfaceLabel: event.target.value })} />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.surface} title</span>
<input className={INPUT_CLASS} value={activeContent.surfaceTitle} onChange={(event) => updateHomepageContent({ surfaceTitle: event.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>{copy.surface} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.surfaceBody} onChange={(event) => updateHomepageContent({ surfaceBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Live label</span>
<input className={INPUT_CLASS} value={activeContent.liveLabel} onChange={(event) => updateHomepageContent({ liveLabel: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Trusted fleets</span>
<input className={INPUT_CLASS} value={activeContent.trustedFleets} onChange={(event) => updateHomepageContent({ trustedFleets: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Branded flows</span>
<input className={INPUT_CLASS} value={activeContent.brandedFlows} onChange={(event) => updateHomepageContent({ brandedFlows: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Multi-tenant</span>
<input className={INPUT_CLASS} value={activeContent.multiTenant} onChange={(event) => updateHomepageContent({ multiTenant: event.target.value })} />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>{copy.companySection} kicker</span>
<input className={INPUT_CLASS} value={activeContent.companyKicker} onChange={(event) => updateHomepageContent({ companyKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.renterSection} kicker</span>
<input className={INPUT_CLASS} value={activeContent.renterKicker} onChange={(event) => updateHomepageContent({ renterKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.companySection} title</span>
<input className={INPUT_CLASS} value={activeContent.companyTitle} onChange={(event) => updateHomepageContent({ companyTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.renterSection} title</span>
<input className={INPUT_CLASS} value={activeContent.renterTitle} onChange={(event) => updateHomepageContent({ renterTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.companySection} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.companyBody} onChange={(event) => updateHomepageContent({ companyBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>{copy.renterSection} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.renterBody} onChange={(event) => updateHomepageContent({ renterBody: event.target.value })} />
</label>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.valueSection}</h3>
<div className="grid gap-4">
{activeContent.metrics.map((metric, index) => (
<label key={metric.value}>
<span className={LABEL_CLASS}>Metric {metric.value}</span>
<input className={INPUT_CLASS} value={metric.label} onChange={(event) => updateMetric(index, event.target.value)} />
</label>
))}
{activeContent.pillars.map((pillar, index) => (
<div key={`${pillar.title}-${index}`} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
<label>
<span className={LABEL_CLASS}>Pillar {index + 1} title</span>
<input className={INPUT_CLASS} value={pillar.title} onChange={(event) => updatePillar(index, { title: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Pillar {index + 1} body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={pillar.body} onChange={(event) => updatePillar(index, { body: event.target.value })} />
</label>
</div>
))}
</div>
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.featureSection}</h3>
<label>
<span className={LABEL_CLASS}>Feature section label</span>
<input className={INPUT_CLASS} value={activeContent.featureLabel} onChange={(event) => updateHomepageContent({ featureLabel: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Features, one per line</span>
<textarea
className={`${INPUT_CLASS} min-h-40`}
value={activeContent.features.join('\n')}
onChange={(event) => updateHomepageContent({ features: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })}
/>
</label>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.stepsSection}</h3>
<label>
<span className={LABEL_CLASS}>Steps title</span>
<input className={INPUT_CLASS} value={activeContent.stepsTitle} onChange={(event) => updateHomepageContent({ stepsTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Step label</span>
<input className={INPUT_CLASS} value={activeContent.stepLabel} onChange={(event) => updateHomepageContent({ stepLabel: event.target.value })} />
</label>
{activeContent.steps.map((step, index) => (
<div key={step.step} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">Step {step.step}</p>
<label>
<span className={LABEL_CLASS}>Title</span>
<input className={INPUT_CLASS} value={step.title} onChange={(event) => updateStep(index, { title: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={step.body} onChange={(event) => updateStep(index, { body: event.target.value })} />
</label>
</div>
))}
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">{copy.closingSection}</h3>
<label>
<span className={LABEL_CLASS}>Closing kicker</span>
<input className={INPUT_CLASS} value={activeContent.readyKicker} onChange={(event) => updateHomepageContent({ readyKicker: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Closing title</span>
<input className={INPUT_CLASS} value={activeContent.readyTitle} onChange={(event) => updateHomepageContent({ readyTitle: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Closing body</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.readyBody} onChange={(event) => updateHomepageContent({ readyBody: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Pricing CTA</span>
<input className={INPUT_CLASS} value={activeContent.viewPricing} onChange={(event) => updateHomepageContent({ viewPricing: event.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Workspace CTA</span>
<input className={INPUT_CLASS} value={activeContent.createWorkspace} onChange={(event) => updateHomepageContent({ createWorkspace: event.target.value })} />
</label>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={saveHomepage}
disabled={homepageSaving}
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{homepageSaving ? copy.savingHomepage : copy.saveHomepage}
</button>
</div>
</>
) : null}
</section>
<section className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4">
<h2 className="text-base font-semibold text-zinc-100">{copy.companiesTitle}</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.company}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.slug}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.status}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td>
</tr>
) : filtered.map((company) => (
<tr key={company.id} className="transition-colors hover:bg-zinc-800/30">
<td className="px-6 py-4">
<p className="font-medium text-zinc-100">{company.brand?.displayName ?? company.name}</p>
<p className="text-xs text-zinc-500">{company.email}</p>
</td>
<td className="px-6 py-4 font-mono text-xs text-zinc-400">{company.slug}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[company.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
{company.status}
</span>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<Link
href={`/dashboard/companies/${company.id}#site-config`}
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
>
{copy.configure}
</Link>
<Link
href={`/dashboard/companies/${company.id}`}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 transition-colors hover:bg-zinc-700"
>
{copy.manageCompany}
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)
}
@@ -19,6 +19,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
nav: {
overview: 'Overview',
companies: 'Companies',
siteConfig: 'Site Config',
renters: 'Renters',
auditLogs: 'Audit Logs',
adminUsers: 'Admin Users',
@@ -34,6 +35,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
nav: {
overview: 'Vue densemble',
companies: 'Entreprises',
siteConfig: 'Config site',
renters: 'Locataires',
auditLogs: 'Journaux daudit',
adminUsers: 'Utilisateurs admin',
@@ -49,6 +51,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
nav: {
overview: 'نظرة عامة',
companies: 'الشركات',
siteConfig: 'إعدادات الموقع',
renters: 'المستأجرون',
auditLogs: 'سجلات التدقيق',
adminUsers: 'مستخدمو الإدارة',