8aab968e09
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s
Comprehensive rename of all marketplace references to storefront: - API module: apps/api/src/modules/marketplace/ → storefront/ - Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell → StorefrontShell, MarketplaceFooter → StorefrontFooter - Types: marketplace-homepage.ts → storefront-homepage.ts - Test files: employee-marketplace-* → employee-storefront-* - All source code identifiers, imports, route paths, and strings - Documentation (docs/), CI config (.gitlab-ci.yml), scripts - Dashboard, admin, storefront workspace references - Prisma field names preserved (isListedOnMarketplace, marketplaceRating, marketplaceFunnelEvent) as they map to database schema Validation: - API type-check: 0 errors - Storefront type-check: 0 errors - Dashboard type-check: 0 errors - Full monorepo type-check: only pre-existing admin TS18046
800 lines
41 KiB
TypeScript
800 lines
41 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import Link from 'next/link'
|
||
import {
|
||
cloneStorefrontHomepageContent,
|
||
resolveStorefrontHomepageSections,
|
||
type StorefrontHomepageConfig,
|
||
type StorefrontHomepageContent,
|
||
type StorefrontHomepageSectionType,
|
||
type StorefrontLanguage,
|
||
} from '@rentaldrivego/types'
|
||
import { useAdminI18n } from '@/components/I18nProvider'
|
||
import { ADMIN_API_BASE } from '@/lib/api'
|
||
|
||
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-orange-400 bg-orange-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: StorefrontHomepageConfig) {
|
||
const cloned = JSON.parse(JSON.stringify(content)) as StorefrontHomepageConfig
|
||
;(['en', 'fr', 'ar'] as StorefrontLanguage[]).forEach((language) => {
|
||
cloned[language].sections = resolveStorefrontHomepageSections(cloned[language].sections)
|
||
})
|
||
return cloned
|
||
}
|
||
|
||
const HOMEPAGE_SECTIONS: StorefrontHomepageSectionType[] = [
|
||
'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 storefront homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||
homepageTitle: 'Main website homepage',
|
||
homepageDescription: 'This controls the storefront homepage shown on the main RentalDriveGo website.',
|
||
saveHomepage: 'Save homepage',
|
||
savingHomepage: 'Saving…',
|
||
homepageSaved: 'Storefront 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 storefront homepage.',
|
||
addSection: 'Add',
|
||
removeSection: 'Remove',
|
||
expand: 'Expand',
|
||
collapse: 'Collapse',
|
||
},
|
||
fr: {
|
||
title: 'Configuration du site',
|
||
description: 'Modifiez ici la page d’accueil principale de la storefront, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||
homepageTitle: 'Page d’accueil du site principal',
|
||
homepageDescription: 'Cette section contrôle la page d’accueil storefront affichée sur le site principal de RentalDriveGo.',
|
||
saveHomepage: 'Enregistrer la page d’accueil',
|
||
savingHomepage: 'Enregistrement…',
|
||
homepageSaved: 'Page d’accueil storefront 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: 'Bloc principal',
|
||
surface: 'Bloc storefront',
|
||
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 page d’accueil',
|
||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil storefront 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<StorefrontHomepageConfig>(cloneStorefrontHomepageContent())
|
||
const [homepageLanguage, setHomepageLanguage] = useState<StorefrontLanguage>(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() {
|
||
try {
|
||
const [companiesRes, homepageRes] = await Promise.all([
|
||
fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||
credentials: 'include',
|
||
cache: 'no-store',
|
||
}),
|
||
fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, {
|
||
credentials: 'include',
|
||
cache: 'no-store',
|
||
}),
|
||
])
|
||
|
||
const companiesJson = await companiesRes.json()
|
||
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
|
||
setCompanies(companiesJson.data?.data ?? [])
|
||
setFiltered(companiesJson.data?.data ?? [])
|
||
|
||
const homepageJson = await homepageRes.json()
|
||
if (homepageRes.ok && homepageJson?.data) {
|
||
setHomepage(cloneHomepageContent(homepageJson.data as StorefrontHomepageConfig))
|
||
}
|
||
} 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<StorefrontHomepageContent>) {
|
||
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 resolveStorefrontHomepageSections(activeContent.sections)
|
||
}
|
||
|
||
function addHomepageSection(section: StorefrontHomepageSectionType) {
|
||
const sections = getActiveSections()
|
||
if (sections.includes(section)) return
|
||
updateHomepageContent({ sections: [...sections, section] })
|
||
}
|
||
|
||
function removeHomepageSection(section: StorefrontHomepageSectionType) {
|
||
const sections = getActiveSections().filter((item) => item !== section)
|
||
if (sections.length === 0) return
|
||
updateHomepageContent({ sections })
|
||
}
|
||
|
||
async function saveHomepage() {
|
||
setHomepageSaving(true)
|
||
setHomepageMessage(null)
|
||
try {
|
||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/storefront-homepage`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'include',
|
||
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 StorefrontHomepageConfig))
|
||
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<StorefrontHomepageSectionType, string> = {
|
||
hero: copy.hero,
|
||
surface: copy.surface,
|
||
pillars: copy.valueSection,
|
||
audiences: `${copy.companySection} / ${copy.renterSection}`,
|
||
features: copy.featureSection,
|
||
howitworks: language === 'fr' ? 'Fonctionnement' : language === 'ar' ? 'كيفية العمل' : 'How it works',
|
||
steps: copy.stepsSection,
|
||
testimonials: language === 'fr' ? 'Témoignages' : language === 'ar' ? 'الشهادات' : 'Testimonials',
|
||
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 StorefrontLanguage[]).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-orange-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-blue-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-orange-600 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-[#06132e] 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-orange-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-[#06132e] 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-orange-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-orange-700 bg-orange-600 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-blue-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-orange-700">{activeContent.renterKicker}</p>
|
||
<h5 className="mt-3 text-2xl font-black text-blue-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-orange-600 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-orange-700">{activeContent.readyKicker}</p>
|
||
<h5 className="mt-3 text-2xl font-black text-blue-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-blue-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-[#06132e] px-6 py-7 text-white">
|
||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-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-orange-300 px-5 py-3 text-sm font-semibold text-blue-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>
|
||
)
|
||
}
|