diff --git a/apps/admin/src/app/dashboard/companies/[id]/HomepageLayoutEditor.tsx b/apps/admin/src/app/dashboard/companies/[id]/HomepageLayoutEditor.tsx new file mode 100644 index 0000000..30a38bc --- /dev/null +++ b/apps/admin/src/app/dashboard/companies/[id]/HomepageLayoutEditor.tsx @@ -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 = { + 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(null) + const [interaction, setInteraction] = useState(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 ( +
+
+
+

Homepage layout

+

Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.

+
+
+ {availableSections.map((type) => ( + + ))} +
+
+ +
+ {layout.items.map((item) => { + const meta = SECTION_META[item.type] + return ( +
+ + +
+

{meta.description}

+
+ x{item.x} y{item.y} + +
+
+ + +
+ ) + })} +
+ +
+ {layout.items.map((item) => { + const meta = SECTION_META[item.type] + return ( +
+
+
+

{meta.label}

+

{meta.description}

+
+ +
+

Desktop drag canvas is available on larger screens.

+
+ ) + })} +
+
+ ) +} diff --git a/apps/admin/src/app/dashboard/companies/[id]/page.tsx b/apps/admin/src/app/dashboard/companies/[id]/page.tsx index 5513332..54fb5e4 100644 --- a/apps/admin/src/app/dashboard/companies/[id]/page.tsx +++ b/apps/admin/src/app/dashboard/companies/[id]/page.tsx @@ -3,6 +3,15 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import Link from 'next/link' +import { + clonePublicHomepageLayout, + clonePublicSitePageSections, + resolvePublicHomepageLayout, + type PublicHomepageLayout, + type PublicHomepageSectionType, + type PublicSitePageSections, +} from '@rentaldrivego/types' +import { HomepageLayoutEditor } from './HomepageLayoutEditor' const API_BASE = '/api/v1' @@ -13,43 +22,411 @@ interface CompanyDetail { email: string phone: string | null status: string + subscriptionPaymentRef: string | null createdAt: string - subscription: { plan: string; status: string; trialEndAt: string | null } | null - _count: { employees: number; vehicles: number; customers: number; reservations: number } + subscription: { + plan: string + billingPeriod: string + status: string + currency: string + trialStartAt: string | null + trialEndAt: string | null + currentPeriodStart: string | null + currentPeriodEnd: string | null + cancelledAt: string | null + cancelAtPeriodEnd: boolean + } | null + brand: { + displayName: string + tagline: string | null + subdomain: string + customDomain: string | null + publicEmail: string | null + publicPhone: string | null + publicAddress: string | null + publicCity: string | null + publicCountry: string | null + websiteUrl: string | null + whatsappNumber: string | null + defaultLocale: string + defaultCurrency: string + isListedOnMarketplace: boolean + 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 + showVehicles: boolean + showPricing: boolean + layout?: PublicHomepageLayout | null + } | 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 + showVehicles: boolean + showOffers: boolean + showPricing: boolean + showBlog: boolean + showContact: boolean + pageSections?: PublicSitePageSections | null + } | null + } | null + contractSettings: { + legalName: string | null + registrationNumber: string | null + taxId: string | null + terms: string + fuelPolicyType: string + lateFeePerHour: number | null + taxRate: number | null + signatureRequired: boolean + showTax: boolean + } | null + accountingSettings: { + reportingPeriod: string + fiscalYearStart: number + currency: string + accountantEmail: string | null + accountantName: string | null + autoSendReport: boolean + reportFormat: string + } | null + employees?: Array<{ id: string }> + vehicles?: Array<{ id: string }> + customers?: Array<{ id: string }> + reservations?: Array<{ id: string }> + _count?: { employees?: number; vehicles?: number; customers?: number; reservations?: number } } interface AuditLog { id: string action: string - entityType: string - entityId: string + resource: string + resourceId: string | null createdAt: string - admin: { email: string } | null + adminUser: { email: string } | null } +interface FormState { + company: { + name: string + slug: string + email: string + phone: string + subscriptionPaymentRef: string + } + subscription: { + plan: string + billingPeriod: string + status: string + currency: string + trialStartAt: string + trialEndAt: string + currentPeriodStart: string + currentPeriodEnd: string + cancelledAt: string + cancelAtPeriodEnd: boolean + } + brand: { + displayName: string + tagline: string + subdomain: string + customDomain: string + publicEmail: string + publicPhone: string + publicAddress: string + publicCity: string + publicCountry: string + websiteUrl: string + whatsappNumber: string + defaultLocale: string + defaultCurrency: string + isListedOnMarketplace: boolean + homePageConfig: { + heroTitle: string + heroDescription: string + viewOffersLabel: string + viewPricingLabel: string + contactCompanyLabel: string + activeOffersTitle: string + seeAllOffersLabel: string + noActiveOffersLabel: string + publishedVehiclesTitle: string + viewVehicleLabel: string + pricingEyebrow: string + pricingTitle: string + pricingDescription: string + showOffers: boolean + showVehicles: boolean + showPricing: boolean + layout: PublicHomepageLayout + } + menuConfig: { + aboutLabel: string + vehiclesLabel: string + offersLabel: string + pricingLabel: string + blogLabel: string + contactLabel: string + bookCarLabel: string + siteNavigationLabel: string + showAbout: boolean + showVehicles: boolean + showOffers: boolean + showPricing: boolean + showBlog: boolean + showContact: boolean + pageSections: PublicSitePageSections + } + } + contractSettings: { + legalName: string + registrationNumber: string + taxId: string + terms: string + fuelPolicyType: string + lateFeePerHour: string + taxRate: string + signatureRequired: boolean + showTax: boolean + } + accountingSettings: { + reportingPeriod: string + fiscalYearStart: string + currency: string + accountantEmail: string + accountantName: string + autoSendReport: boolean + reportFormat: string + } +} + +type CompanyHomePageConfig = NonNullable['homePageConfig'] + const STATUS_COLORS: Record = { ACTIVE: 'text-emerald-400', TRIALING: 'text-sky-400', SUSPENDED: 'text-red-400', PENDING: 'text-amber-400', CANCELLED: 'text-zinc-400', + PAST_DUE: 'text-orange-400', +} + +const PREVIEW_PRICING_PLANS = [ + { name: 'Starter', price: 'MAD 499', cadence: '/month' }, + { name: 'Growth', price: 'MAD 999', cadence: '/month', highlighted: true }, + { name: 'Pro', price: 'Custom', cadence: '' }, +] + +const HOMEPAGE_SECTION_ORDER: PublicHomepageSectionType[] = ['hero', 'offers', 'vehicles', 'pricing'] + +const PAGE_SECTION_GROUPS: Array<{ + page: keyof PublicSitePageSections + label: string + sections: Array<{ key: string; label: string }> +}> = [ + { page: 'home', label: 'Homepage', sections: [{ key: 'hero', label: 'Hero' }, { key: 'offers', label: 'Offers' }, { key: 'vehicles', label: 'Vehicles' }, { key: 'pricing', label: 'Pricing' }] }, + { page: 'about', label: 'About', sections: [{ key: 'hero', label: 'Hero' }, { key: 'highlights', label: 'Highlights' }, { key: 'details', label: 'Details' }] }, + { page: 'offers', label: 'Offers', sections: [{ key: 'header', label: 'Header' }, { key: 'grid', label: 'Offers grid' }] }, + { page: 'pricing', label: 'Pricing', sections: [{ key: 'hero', label: 'Hero' }, { key: 'plans', label: 'Plans' }, { key: 'payments', label: 'Payments' }, { key: 'faq', label: 'FAQ' }] }, + { page: 'vehicles', label: 'Vehicles', sections: [{ key: 'header', label: 'Header' }, { key: 'filters', label: 'Filters' }, { key: 'grid', label: 'Vehicle grid' }] }, + { page: 'vehicleDetail', label: 'Vehicle detail', sections: [{ key: 'gallery', label: 'Gallery' }, { key: 'summary', label: 'Summary card' }] }, + { page: 'blog', label: 'Blog', sections: [{ key: 'hero', label: 'Hero' }, { key: 'posts', label: 'Posts' }] }, + { page: 'contact', label: 'Contact', sections: [{ key: 'content', label: 'Content card' }] }, + { page: 'booking', label: 'Booking', sections: [{ key: 'content', label: 'Booking form' }] }, + { page: 'bookingConfirmation', label: 'Booking confirmation', sections: [{ key: 'hero', label: 'Confirmation header' }, { key: 'summary', label: 'Booking summary' }, { key: 'actions', label: 'Action buttons' }] }, +] + +const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500' +const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500' + +function getToken() { + return localStorage.getItem('admin_token') ?? '' +} + +function toDateInput(value: string | null | undefined) { + return value ? new Date(value).toISOString().slice(0, 10) : '' +} + +function toNullable(value: string) { + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +function resolvePreviewText(value: string, fallback: string) { + const trimmed = value.trim() + return trimmed ? trimmed : fallback +} + +function resolveLegacyHomepageVisibility( + config: CompanyHomePageConfig | null | undefined, + type: Exclude, +) { + if (type === 'offers') return config?.showOffers ?? true + if (type === 'vehicles') return config?.showVehicles ?? true + return config?.showPricing ?? true +} + +function buildHomepageLayout(company: CompanyDetail): PublicHomepageLayout { + const pageSections = mergePageSectionsConfig(company.brand?.menuConfig?.pageSections) + const visibleTypes = HOMEPAGE_SECTION_ORDER.filter((type) => { + if (!pageSections.home[type]) return false + if (type === 'hero') return true + return resolveLegacyHomepageVisibility(company.brand?.homePageConfig ?? null, type) + }) + + return resolvePublicHomepageLayout(company.brand?.homePageConfig?.layout, visibleTypes) +} + +function mergePageSectionsConfig(config?: PublicSitePageSections | null): PublicSitePageSections { + const defaults = clonePublicSitePageSections() + return { + ...defaults, + ...(config ?? {}), + home: { ...defaults.home, ...(config?.home ?? {}) }, + about: { ...defaults.about, ...(config?.about ?? {}) }, + offers: { ...defaults.offers, ...(config?.offers ?? {}) }, + pricing: { ...defaults.pricing, ...(config?.pricing ?? {}) }, + vehicles: { ...defaults.vehicles, ...(config?.vehicles ?? {}) }, + vehicleDetail: { ...defaults.vehicleDetail, ...(config?.vehicleDetail ?? {}) }, + blog: { ...defaults.blog, ...(config?.blog ?? {}) }, + contact: { ...defaults.contact, ...(config?.contact ?? {}) }, + booking: { ...defaults.booking, ...(config?.booking ?? {}) }, + bookingConfirmation: { + ...defaults.bookingConfirmation, + ...(config?.bookingConfirmation ?? {}), + }, + } +} + +function createFormState(company: CompanyDetail): FormState { + return { + company: { + name: company.name ?? '', + slug: company.slug ?? '', + email: company.email ?? '', + phone: company.phone ?? '', + subscriptionPaymentRef: company.subscriptionPaymentRef ?? '', + }, + subscription: { + plan: company.subscription?.plan ?? 'STARTER', + billingPeriod: company.subscription?.billingPeriod ?? 'MONTHLY', + status: company.subscription?.status ?? 'TRIALING', + currency: company.subscription?.currency ?? 'MAD', + trialStartAt: toDateInput(company.subscription?.trialStartAt), + trialEndAt: toDateInput(company.subscription?.trialEndAt), + currentPeriodStart: toDateInput(company.subscription?.currentPeriodStart), + currentPeriodEnd: toDateInput(company.subscription?.currentPeriodEnd), + cancelledAt: toDateInput(company.subscription?.cancelledAt), + cancelAtPeriodEnd: company.subscription?.cancelAtPeriodEnd ?? false, + }, + brand: { + displayName: company.brand?.displayName ?? company.name ?? '', + tagline: company.brand?.tagline ?? '', + subdomain: company.brand?.subdomain ?? company.slug ?? '', + customDomain: company.brand?.customDomain ?? '', + publicEmail: company.brand?.publicEmail ?? '', + publicPhone: company.brand?.publicPhone ?? '', + publicAddress: company.brand?.publicAddress ?? '', + publicCity: company.brand?.publicCity ?? '', + publicCountry: company.brand?.publicCountry ?? '', + websiteUrl: company.brand?.websiteUrl ?? '', + whatsappNumber: company.brand?.whatsappNumber ?? '', + defaultLocale: company.brand?.defaultLocale ?? 'en', + defaultCurrency: company.brand?.defaultCurrency ?? 'MAD', + isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true, + homePageConfig: { + heroTitle: company.brand?.homePageConfig?.heroTitle ?? '', + heroDescription: company.brand?.homePageConfig?.heroDescription ?? '', + viewOffersLabel: company.brand?.homePageConfig?.viewOffersLabel ?? '', + viewPricingLabel: company.brand?.homePageConfig?.viewPricingLabel ?? '', + contactCompanyLabel: company.brand?.homePageConfig?.contactCompanyLabel ?? '', + activeOffersTitle: company.brand?.homePageConfig?.activeOffersTitle ?? '', + seeAllOffersLabel: company.brand?.homePageConfig?.seeAllOffersLabel ?? '', + noActiveOffersLabel: company.brand?.homePageConfig?.noActiveOffersLabel ?? '', + publishedVehiclesTitle: company.brand?.homePageConfig?.publishedVehiclesTitle ?? '', + viewVehicleLabel: company.brand?.homePageConfig?.viewVehicleLabel ?? '', + pricingEyebrow: company.brand?.homePageConfig?.pricingEyebrow ?? '', + pricingTitle: company.brand?.homePageConfig?.pricingTitle ?? '', + pricingDescription: company.brand?.homePageConfig?.pricingDescription ?? '', + showOffers: company.brand?.homePageConfig?.showOffers ?? true, + showVehicles: company.brand?.homePageConfig?.showVehicles ?? true, + showPricing: company.brand?.homePageConfig?.showPricing ?? true, + layout: buildHomepageLayout(company), + }, + menuConfig: { + aboutLabel: company.brand?.menuConfig?.aboutLabel ?? '', + vehiclesLabel: company.brand?.menuConfig?.vehiclesLabel ?? '', + offersLabel: company.brand?.menuConfig?.offersLabel ?? '', + pricingLabel: company.brand?.menuConfig?.pricingLabel ?? '', + blogLabel: company.brand?.menuConfig?.blogLabel ?? '', + contactLabel: company.brand?.menuConfig?.contactLabel ?? '', + bookCarLabel: company.brand?.menuConfig?.bookCarLabel ?? '', + siteNavigationLabel: company.brand?.menuConfig?.siteNavigationLabel ?? '', + showAbout: company.brand?.menuConfig?.showAbout ?? true, + showVehicles: company.brand?.menuConfig?.showVehicles ?? true, + showOffers: company.brand?.menuConfig?.showOffers ?? true, + showPricing: company.brand?.menuConfig?.showPricing ?? true, + showBlog: company.brand?.menuConfig?.showBlog ?? true, + showContact: company.brand?.menuConfig?.showContact ?? true, + pageSections: mergePageSectionsConfig(company.brand?.menuConfig?.pageSections), + }, + }, + contractSettings: { + legalName: company.contractSettings?.legalName ?? '', + registrationNumber: company.contractSettings?.registrationNumber ?? '', + taxId: company.contractSettings?.taxId ?? '', + terms: company.contractSettings?.terms ?? '', + fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL', + lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '', + taxRate: company.contractSettings?.taxRate?.toString() ?? '', + signatureRequired: company.contractSettings?.signatureRequired ?? true, + showTax: company.contractSettings?.showTax ?? false, + }, + accountingSettings: { + reportingPeriod: company.accountingSettings?.reportingPeriod ?? 'MONTHLY', + fiscalYearStart: company.accountingSettings?.fiscalYearStart?.toString() ?? '1', + currency: company.accountingSettings?.currency ?? 'MAD', + accountantEmail: company.accountingSettings?.accountantEmail ?? '', + accountantName: company.accountingSettings?.accountantName ?? '', + autoSendReport: company.accountingSettings?.autoSendReport ?? false, + reportFormat: company.accountingSettings?.reportFormat ?? 'PDF', + }, + } } export default function AdminCompanyDetailPage() { const { id } = useParams<{ id: string }>() const router = useRouter() const [company, setCompany] = useState(null) + const [form, setForm] = useState(null) const [auditLogs, setAuditLogs] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [actioning, setActioning] = useState(false) + const [saving, setSaving] = useState(false) + const [savedMessage, setSavedMessage] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(false) - function getToken() { return localStorage.getItem('admin_token') ?? '' } - async function fetchData() { - const token = getToken() - const headers = { Authorization: `Bearer ${token}` } + const headers = { Authorization: `Bearer ${getToken()}` } try { const [cRes, aRes] = await Promise.all([ fetch(`${API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }), @@ -59,7 +436,9 @@ export default function AdminCompanyDetailPage() { const aJson = await aRes.json() if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found') setCompany(cJson.data) + setForm(createFormState(cJson.data)) setAuditLogs(aJson.data ?? []) + setError(null) } catch (err: any) { setError(err.message) } finally { @@ -69,15 +448,285 @@ export default function AdminCompanyDetailPage() { useEffect(() => { fetchData() }, [id]) + function updateSection(section: K, patch: Partial) { + setForm((current) => current ? { ...current, [section]: { ...current[section], ...patch } } : current) + } + + function updateBrandConfig( + section: K, + patch: Partial, + ) { + setForm((current) => current ? { + ...current, + brand: { + ...current.brand, + [section]: { + ...current.brand[section], + ...patch, + }, + }, + } : current) + } + + function updateHomepageLayout(layout: PublicHomepageLayout) { + setForm((current) => current ? { + ...current, + brand: { + ...current.brand, + homePageConfig: { + ...current.brand.homePageConfig, + layout, + }, + }, + } : current) + } + + function addHomepageSection(type: PublicHomepageSectionType) { + setForm((current) => { + if (!current) return current + if (current.brand.homePageConfig.layout.items.some((item) => item.type === type)) return current + const fallback = clonePublicHomepageLayout().items.find((item) => item.type === type) + if (!fallback) return current + + return { + ...current, + brand: { + ...current.brand, + homePageConfig: { + ...current.brand.homePageConfig, + showOffers: type === 'offers' ? true : current.brand.homePageConfig.showOffers, + showVehicles: type === 'vehicles' ? true : current.brand.homePageConfig.showVehicles, + showPricing: type === 'pricing' ? true : current.brand.homePageConfig.showPricing, + layout: { + items: [...current.brand.homePageConfig.layout.items, fallback], + }, + }, + menuConfig: { + ...current.brand.menuConfig, + pageSections: { + ...current.brand.menuConfig.pageSections, + home: { + ...current.brand.menuConfig.pageSections.home, + [type]: true, + }, + }, + }, + }, + } + }) + } + + function removeHomepageSection(type: PublicHomepageSectionType) { + setForm((current) => current ? { + ...current, + brand: { + ...current.brand, + homePageConfig: { + ...current.brand.homePageConfig, + showOffers: type === 'offers' ? false : current.brand.homePageConfig.showOffers, + showVehicles: type === 'vehicles' ? false : current.brand.homePageConfig.showVehicles, + showPricing: type === 'pricing' ? false : current.brand.homePageConfig.showPricing, + layout: { + items: current.brand.homePageConfig.layout.items.filter((item) => item.type !== type), + }, + }, + menuConfig: { + ...current.brand.menuConfig, + pageSections: { + ...current.brand.menuConfig.pageSections, + home: { + ...current.brand.menuConfig.pageSections.home, + [type]: false, + }, + }, + }, + }, + } : current) + } + + function updatePageSection< + P extends keyof PublicSitePageSections, + S extends keyof PublicSitePageSections[P] + >(page: P, section: S, value: boolean) { + setForm((current) => { + if (!current) return current + + const next = { + ...current, + brand: { + ...current.brand, + menuConfig: { + ...current.brand.menuConfig, + pageSections: { + ...current.brand.menuConfig.pageSections, + [page]: { + ...current.brand.menuConfig.pageSections[page], + [section]: value, + }, + }, + }, + }, + } + + if (page !== 'home') return next + + const homepageSection = section as PublicHomepageSectionType + const nextItems = value + ? next.brand.homePageConfig.layout.items.some((item) => item.type === homepageSection) + ? next.brand.homePageConfig.layout.items + : [...next.brand.homePageConfig.layout.items, clonePublicHomepageLayout().items.find((item) => item.type === homepageSection)!] + : next.brand.homePageConfig.layout.items.filter((item) => item.type !== homepageSection) + + return { + ...next, + brand: { + ...next.brand, + homePageConfig: { + ...next.brand.homePageConfig, + showOffers: homepageSection === 'offers' ? value : next.brand.homePageConfig.showOffers, + showVehicles: homepageSection === 'vehicles' ? value : next.brand.homePageConfig.showVehicles, + showPricing: homepageSection === 'pricing' ? value : next.brand.homePageConfig.showPricing, + layout: { items: nextItems }, + }, + }, + } + }) + } + + function getPageSectionValue< + P extends keyof PublicSitePageSections, + S extends keyof PublicSitePageSections[P] + >(page: P, section: S) { + return form?.brand.menuConfig.pageSections[page][section] ?? false + } + + async function saveChanges() { + if (!form) return + setSaving(true) + setSavedMessage(null) + setError(null) + try { + const res = await fetch(`${API_BASE}/admin/companies/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ + company: { + name: form.company.name, + slug: form.company.slug, + email: form.company.email, + phone: toNullable(form.company.phone), + subscriptionPaymentRef: toNullable(form.company.subscriptionPaymentRef), + }, + subscription: { + plan: form.subscription.plan, + billingPeriod: form.subscription.billingPeriod, + status: form.subscription.status, + currency: form.subscription.currency, + trialStartAt: toNullable(form.subscription.trialStartAt), + trialEndAt: toNullable(form.subscription.trialEndAt), + currentPeriodStart: toNullable(form.subscription.currentPeriodStart), + currentPeriodEnd: toNullable(form.subscription.currentPeriodEnd), + cancelledAt: toNullable(form.subscription.cancelledAt), + cancelAtPeriodEnd: form.subscription.cancelAtPeriodEnd, + }, + brand: { + displayName: form.brand.displayName, + tagline: toNullable(form.brand.tagline), + subdomain: form.brand.subdomain, + customDomain: toNullable(form.brand.customDomain), + publicEmail: toNullable(form.brand.publicEmail), + publicPhone: toNullable(form.brand.publicPhone), + publicAddress: toNullable(form.brand.publicAddress), + publicCity: toNullable(form.brand.publicCity), + publicCountry: toNullable(form.brand.publicCountry), + websiteUrl: toNullable(form.brand.websiteUrl), + whatsappNumber: toNullable(form.brand.whatsappNumber), + defaultLocale: form.brand.defaultLocale, + defaultCurrency: form.brand.defaultCurrency, + isListedOnMarketplace: form.brand.isListedOnMarketplace, + homePageConfig: { + heroTitle: toNullable(form.brand.homePageConfig.heroTitle), + heroDescription: toNullable(form.brand.homePageConfig.heroDescription), + viewOffersLabel: toNullable(form.brand.homePageConfig.viewOffersLabel), + viewPricingLabel: toNullable(form.brand.homePageConfig.viewPricingLabel), + contactCompanyLabel: toNullable(form.brand.homePageConfig.contactCompanyLabel), + activeOffersTitle: toNullable(form.brand.homePageConfig.activeOffersTitle), + seeAllOffersLabel: toNullable(form.brand.homePageConfig.seeAllOffersLabel), + noActiveOffersLabel: toNullable(form.brand.homePageConfig.noActiveOffersLabel), + publishedVehiclesTitle: toNullable(form.brand.homePageConfig.publishedVehiclesTitle), + viewVehicleLabel: toNullable(form.brand.homePageConfig.viewVehicleLabel), + pricingEyebrow: toNullable(form.brand.homePageConfig.pricingEyebrow), + pricingTitle: toNullable(form.brand.homePageConfig.pricingTitle), + pricingDescription: toNullable(form.brand.homePageConfig.pricingDescription), + showOffers: form.brand.homePageConfig.showOffers, + showVehicles: form.brand.homePageConfig.showVehicles, + showPricing: form.brand.homePageConfig.showPricing, + layout: form.brand.homePageConfig.layout, + }, + menuConfig: { + aboutLabel: toNullable(form.brand.menuConfig.aboutLabel), + vehiclesLabel: toNullable(form.brand.menuConfig.vehiclesLabel), + offersLabel: toNullable(form.brand.menuConfig.offersLabel), + pricingLabel: toNullable(form.brand.menuConfig.pricingLabel), + blogLabel: toNullable(form.brand.menuConfig.blogLabel), + contactLabel: toNullable(form.brand.menuConfig.contactLabel), + bookCarLabel: toNullable(form.brand.menuConfig.bookCarLabel), + siteNavigationLabel: toNullable(form.brand.menuConfig.siteNavigationLabel), + showAbout: form.brand.menuConfig.showAbout, + showVehicles: form.brand.menuConfig.showVehicles, + showOffers: form.brand.menuConfig.showOffers, + showPricing: form.brand.menuConfig.showPricing, + showBlog: form.brand.menuConfig.showBlog, + showContact: form.brand.menuConfig.showContact, + pageSections: form.brand.menuConfig.pageSections, + }, + }, + contractSettings: { + legalName: toNullable(form.contractSettings.legalName), + registrationNumber: toNullable(form.contractSettings.registrationNumber), + taxId: toNullable(form.contractSettings.taxId), + terms: form.contractSettings.terms, + fuelPolicyType: form.contractSettings.fuelPolicyType, + lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null, + taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null, + signatureRequired: form.contractSettings.signatureRequired, + showTax: form.contractSettings.showTax, + }, + accountingSettings: { + reportingPeriod: form.accountingSettings.reportingPeriod, + fiscalYearStart: Number(form.accountingSettings.fiscalYearStart || 1), + currency: form.accountingSettings.currency, + accountantEmail: toNullable(form.accountingSettings.accountantEmail), + accountantName: toNullable(form.accountingSettings.accountantName), + autoSendReport: form.accountingSettings.autoSendReport, + reportFormat: form.accountingSettings.reportFormat, + }, + }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Save failed') + setCompany(json.data) + setForm(createFormState(json.data)) + setSavedMessage('Company settings updated.') + await fetchData() + } catch (err: any) { + setError(err.message) + } finally { + setSaving(false) + } + } + async function changeStatus(status: string) { setActioning(true) + setError(null) try { const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, body: JSON.stringify({ status }), }) - if (!res.ok) throw new Error('Action failed') + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Action failed') await fetchData() } catch (err: any) { setError(err.message) @@ -93,7 +742,8 @@ export default function AdminCompanyDetailPage() { method: 'DELETE', headers: { Authorization: `Bearer ${getToken()}` }, }) - if (!res.ok) throw new Error('Delete failed') + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Delete failed') router.push('/dashboard/companies') } catch (err: any) { setError(err.message) @@ -103,30 +753,197 @@ export default function AdminCompanyDetailPage() { if (loading) return
Loading…
if (error && !company) return
{error}
- if (!company) return null + if (!company || !form) return null + + const counts = { + employees: company._count?.employees ?? company.employees?.length ?? 0, + vehicles: company._count?.vehicles ?? company.vehicles?.length ?? 0, + customers: company._count?.customers ?? company.customers?.length ?? 0, + reservations: company._count?.reservations ?? company.reservations?.length ?? 0, + } + const previewCompanyName = form.brand.displayName.trim() || form.company.name.trim() || company.name + const previewTagline = resolvePreviewText( + form.brand.tagline, + 'This company uses RentalDriveGo to publish vehicles, run bookings, and accept direct rental payments.', + ) + const previewHeroTitle = resolvePreviewText(form.brand.homePageConfig.heroTitle, previewTagline) + const previewHeroDescription = resolvePreviewText( + form.brand.homePageConfig.heroDescription, + 'Manage direct bookings, highlight active offers, and turn your fleet into a modern rental storefront.', + ) + const previewOffersLabel = resolvePreviewText(form.brand.homePageConfig.viewOffersLabel, 'View offers') + const previewPricingLabel = resolvePreviewText(form.brand.homePageConfig.viewPricingLabel, 'View pricing') + const previewContactLabel = resolvePreviewText(form.brand.homePageConfig.contactCompanyLabel, 'Contact company') + const previewOffersTitle = resolvePreviewText(form.brand.homePageConfig.activeOffersTitle, 'Active offers') + const previewSeeAllOffersLabel = resolvePreviewText(form.brand.homePageConfig.seeAllOffersLabel, 'See all offers') + const previewNoOffersLabel = resolvePreviewText(form.brand.homePageConfig.noActiveOffersLabel, 'No active offers right now') + const previewVehiclesTitle = resolvePreviewText(form.brand.homePageConfig.publishedVehiclesTitle, 'Published vehicles') + const previewViewVehicleLabel = resolvePreviewText(form.brand.homePageConfig.viewVehicleLabel, 'View vehicle') + const previewPricingEyebrow = resolvePreviewText(form.brand.homePageConfig.pricingEyebrow, 'Pricing') + const previewPricingTitle = resolvePreviewText(form.brand.homePageConfig.pricingTitle, 'Plans for every fleet size') + const previewPricingDescription = resolvePreviewText( + form.brand.homePageConfig.pricingDescription, + 'Showcase transparent pricing and make it easy for renters to understand the value of booking direct.', + ) + const previewBookCarLabel = resolvePreviewText(form.brand.menuConfig.bookCarLabel, 'Book car') + const previewFooterLabel = resolvePreviewText(form.brand.menuConfig.siteNavigationLabel, 'Site navigation') + const homepageLayout = resolvePublicHomepageLayout( + form.brand.homePageConfig.layout, + form.brand.homePageConfig.layout.items.map((item) => item.type), + ) + const availableHomepageSections = HOMEPAGE_SECTION_ORDER.filter( + (type) => !homepageLayout.items.some((item) => item.type === type), + ) + const previewMenuItems = [ + { label: resolvePreviewText(form.brand.menuConfig.aboutLabel, 'About'), visible: form.brand.menuConfig.showAbout }, + { label: resolvePreviewText(form.brand.menuConfig.vehiclesLabel, 'Vehicles'), visible: form.brand.menuConfig.showVehicles }, + { label: resolvePreviewText(form.brand.menuConfig.offersLabel, 'Offers'), visible: form.brand.menuConfig.showOffers }, + { label: resolvePreviewText(form.brand.menuConfig.pricingLabel, 'Pricing'), visible: form.brand.menuConfig.showPricing }, + { label: resolvePreviewText(form.brand.menuConfig.blogLabel, 'Blog'), visible: form.brand.menuConfig.showBlog }, + { label: resolvePreviewText(form.brand.menuConfig.contactLabel, 'Contact'), visible: form.brand.menuConfig.showContact }, + ].filter((item) => item.visible) + + function renderHomepagePreviewSection(type: PublicHomepageSectionType) { + if (type === 'hero') { + return ( +
+
+

{previewCompanyName}

+

{previewHeroTitle}

+

{previewHeroDescription}

+
+ {previewOffersLabel} + {previewPricingLabel} + {previewContactLabel} +
+
+
+ Hero image preview area. +
+ Published brand media will render here on the public site. +
+
+ ) + } + + if (type === 'offers') { + return ( +
+
+

{previewOffersTitle}

+ {previewSeeAllOffersLabel} +
+
+
+

Sample offer

+
Weekend escape
+

15%

+
+
+ {previewNoOffersLabel} +
+
+
+ ) + } + + if (type === 'vehicles') { + return ( +
+
+

{previewVehiclesTitle}

+

{counts.vehicles} published vehicles

+
+
+ {[1, 2].map((index) => ( +
+
+
+
Vehicle {index}
+

Category

+
+

MAD 850/day

+ {previewViewVehicleLabel} +
+
+
+ ))} +
+
+ ) + } + + return ( +
+
+
+

{previewPricingEyebrow}

+

{previewPricingTitle}

+

{previewPricingDescription}

+
+ + {previewPricingLabel} + +
+ +
+ {PREVIEW_PRICING_PLANS.slice(0, 2).map((plan) => ( +
+
+
+
{plan.name}
+

Pricing copy preview for the public homepage.

+
+ {plan.highlighted ? ( + + Popular + + ) : null} +
+
+ {plan.price} + {plan.cadence ? {plan.cadence} : null} +
+
+ ))} +
+
+ ) + } return ( -
-
- ← Companies +
+
+ ← Companies +
-
+

{company.name}

-

{company.slug}

+

{company.slug}

{company.status}
{error &&
{error}
} + {savedMessage &&
{savedMessage}
}
{[ - { label: 'Employees', value: company._count.employees }, - { label: 'Vehicles', value: company._count.vehicles }, - { label: 'Customers', value: company._count.customers }, - { label: 'Reservations', value: company._count.reservations }, + { label: 'Employees', value: counts.employees }, + { label: 'Vehicles', value: counts.vehicles }, + { label: 'Customers', value: counts.customers }, + { label: 'Reservations', value: counts.reservations }, ].map((kpi) => (

{kpi.label}

@@ -135,70 +952,556 @@ export default function AdminCompanyDetailPage() { ))}
+
+
+

Company

+
+ + + + + +
+
+ +
+

Subscription

+
+ + + + + + + + + + +
+
+ +
+

Brand and public profile

+
+ + + + + + + + + + + + + + +
+
+ +
+
+
+

Public site homepage and menu

+

Blank text fields fall back to the default localized copy on the public site.

+
+
+ +
+
+

Homepage content

+
+ +