remove company website pages

This commit is contained in:
root
2026-05-17 08:53:19 -04:00
parent f52e53519c
commit 84285335a4
58 changed files with 106 additions and 5065 deletions
@@ -1,232 +0,0 @@
'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
const snap = interaction
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 - snap.startX) / colWidth)
const deltaRows = Math.round((event.clientY - snap.startY) / ROW_HEIGHT)
const limits = getPublicHomepageSectionLimits(snap.origin.type)
onChange({
items: layout.items.map((item) => {
if (item.id !== snap.itemId) return item
if (snap.mode === 'move') {
return {
...item,
x: clamp(snap.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.w + 1),
y: clamp(snap.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - snap.origin.h + 1),
}
}
const nextW = clamp(
snap.origin.w + deltaCols,
limits.minW,
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - snap.origin.x + 1),
)
const nextH = clamp(
snap.origin.h + deltaRows,
limits.minH,
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - snap.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>
)
}
@@ -3,15 +3,6 @@
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'
@@ -51,42 +42,6 @@ interface CompanyDetail {
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
@@ -159,42 +114,6 @@ interface FormState {
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
@@ -218,8 +137,6 @@ interface FormState {
}
}
type CompanyHomePageConfig = NonNullable<CompanyDetail['brand']>['homePageConfig']
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400',
TRIALING: 'text-sky-400',
@@ -229,31 +146,6 @@ const STATUS_COLORS: Record<string, string> = {
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'
@@ -270,52 +162,6 @@ function toNullable(value: string) {
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<PublicHomepageSectionType, 'hero'>,
) {
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: {
@@ -352,42 +198,6 @@ function createFormState(company: CompanyDetail): FormState {
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 ?? '',
@@ -452,154 +262,6 @@ export default function AdminCompanyDetailPage() {
setForm((current) => current ? { ...current, [section]: { ...current[section], ...patch } } : current)
}
function updateBrandConfig<K extends keyof FormState['brand'] & ('homePageConfig' | 'menuConfig')>(
section: K,
patch: Partial<FormState['brand'][K]>,
) {
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)
@@ -644,42 +306,6 @@ export default function AdminCompanyDetailPage() {
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),
@@ -761,158 +387,6 @@ export default function AdminCompanyDetailPage() {
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 (
<section className="grid h-full gap-6 lg:grid-cols-[1.15fr_0.85fr] lg:items-center">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{previewCompanyName}</p>
<h4 className="mt-5 text-4xl font-black tracking-tight text-slate-900">{previewHeroTitle}</h4>
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-600">{previewHeroDescription}</p>
<div className="mt-8 flex flex-wrap gap-3">
<span className="rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-white">{previewOffersLabel}</span>
<span className="rounded-full border border-sky-200 bg-sky-50 px-5 py-3 text-sm font-semibold text-sky-800">{previewPricingLabel}</span>
<span className="rounded-full border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700">{previewContactLabel}</span>
</div>
</div>
<div className="flex min-h-72 items-center justify-center rounded-[1.5rem] bg-slate-100 p-8 text-center text-sm leading-7 text-slate-500">
Hero image preview area.
<br />
Published brand media will render here on the public site.
</div>
</section>
)
}
if (type === 'offers') {
return (
<section className="h-full">
<div className="flex items-center justify-between gap-3">
<h4 className="text-2xl font-bold text-slate-900">{previewOffersTitle}</h4>
<span className="text-sm font-semibold text-sky-700">{previewSeeAllOffersLabel}</span>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700">Sample offer</p>
<h5 className="mt-3 text-lg font-semibold text-slate-900">Weekend escape</h5>
<p className="mt-3 text-3xl font-black text-sky-700">15%</p>
</div>
<div className="rounded-[1.5rem] border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500 shadow-sm">
{previewNoOffersLabel}
</div>
</div>
</section>
)
}
if (type === 'vehicles') {
return (
<section className="h-full">
<div className="flex items-center justify-between gap-3">
<h4 className="text-2xl font-bold text-slate-900">{previewVehiclesTitle}</h4>
<p className="text-sm text-slate-500">{counts.vehicles} published vehicles</p>
</div>
<div className="mt-4 grid gap-5 md:grid-cols-2">
{[1, 2].map((index) => (
<article key={index} className="overflow-hidden rounded-[1.5rem] border border-slate-200 bg-white shadow-sm">
<div className="aspect-[16/10] bg-[linear-gradient(135deg,#e2e8f0,#f8fafc)]" />
<div className="p-5">
<h5 className="text-xl font-bold text-slate-900">Vehicle {index}</h5>
<p className="mt-1 text-sm text-slate-500">Category</p>
<div className="mt-4 flex items-end justify-between gap-3">
<p className="text-xl font-black text-slate-900">MAD 850/day</p>
<span className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white">{previewViewVehicleLabel}</span>
</div>
</div>
</article>
))}
</div>
</section>
)
}
return (
<section className="h-full space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{previewPricingEyebrow}</p>
<h4 className="mt-2 text-3xl font-black tracking-tight text-slate-900">{previewPricingTitle}</h4>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-600">{previewPricingDescription}</p>
</div>
<span className="rounded-full border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-700">
{previewPricingLabel}
</span>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{PREVIEW_PRICING_PLANS.slice(0, 2).map((plan) => (
<article
key={plan.name}
className={`rounded-[1.5rem] border bg-white p-8 shadow-sm ${plan.highlighted ? 'border-sky-300 shadow-lg shadow-sky-100' : 'border-slate-200'}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<h5 className="text-2xl font-black text-slate-900">{plan.name}</h5>
<p className="mt-3 text-sm leading-6 text-slate-600">Pricing copy preview for the public homepage.</p>
</div>
{plan.highlighted ? (
<span className="rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-sky-800">
Popular
</span>
) : null}
</div>
<div className="mt-8 flex items-end gap-2">
<span className="text-4xl font-black tracking-tight text-slate-900">{plan.price}</span>
{plan.cadence ? <span className="pb-1 text-sm text-slate-500">{plan.cadence}</span> : null}
</div>
</article>
))}
</div>
</section>
)
}
return (
<div className="shell space-y-6 py-8">
@@ -1108,253 +582,6 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section id="site-config" className="panel p-6 xl:col-span-2">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-base font-semibold">Public site homepage and menu</h2>
<p className="mt-1 text-sm text-zinc-500">Blank text fields fall back to the default localized copy on the public site.</p>
</div>
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">Homepage content</h3>
<div className="grid gap-4 md:grid-cols-2">
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Hero title</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.heroTitle} onChange={(e) => updateBrandConfig('homePageConfig', { heroTitle: e.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Hero description</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={form.brand.homePageConfig.heroDescription} onChange={(e) => updateBrandConfig('homePageConfig', { heroDescription: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Offers button label</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.viewOffersLabel} onChange={(e) => updateBrandConfig('homePageConfig', { viewOffersLabel: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Pricing button label</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.viewPricingLabel} onChange={(e) => updateBrandConfig('homePageConfig', { viewPricingLabel: e.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Contact button label</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.contactCompanyLabel} onChange={(e) => updateBrandConfig('homePageConfig', { contactCompanyLabel: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Offers section title</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.activeOffersTitle} onChange={(e) => updateBrandConfig('homePageConfig', { activeOffersTitle: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>See all offers label</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.seeAllOffersLabel} onChange={(e) => updateBrandConfig('homePageConfig', { seeAllOffersLabel: e.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>No active offers text</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.noActiveOffersLabel} onChange={(e) => updateBrandConfig('homePageConfig', { noActiveOffersLabel: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Vehicles section title</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.publishedVehiclesTitle} onChange={(e) => updateBrandConfig('homePageConfig', { publishedVehiclesTitle: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>View vehicle label</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.viewVehicleLabel} onChange={(e) => updateBrandConfig('homePageConfig', { viewVehicleLabel: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Pricing eyebrow</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.pricingEyebrow} onChange={(e) => updateBrandConfig('homePageConfig', { pricingEyebrow: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Pricing title</span>
<input className={INPUT_CLASS} value={form.brand.homePageConfig.pricingTitle} onChange={(e) => updateBrandConfig('homePageConfig', { pricingTitle: e.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Pricing description</span>
<textarea className={`${INPUT_CLASS} min-h-24`} value={form.brand.homePageConfig.pricingDescription} onChange={(e) => updateBrandConfig('homePageConfig', { pricingDescription: e.target.value })} />
</label>
</div>
<HomepageLayoutEditor
layout={homepageLayout}
availableSections={availableHomepageSections}
onChange={updateHomepageLayout}
onAddSection={addHomepageSection}
onRemoveSection={removeHomepageSection}
/>
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-zinc-200">Navigation menu</h3>
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>About label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.aboutLabel} onChange={(e) => updateBrandConfig('menuConfig', { aboutLabel: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.menuConfig.showAbout} onChange={(e) => updateBrandConfig('menuConfig', { showAbout: e.target.checked })} />
Show About
</label>
<label>
<span className={LABEL_CLASS}>Vehicles label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.vehiclesLabel} onChange={(e) => updateBrandConfig('menuConfig', { vehiclesLabel: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.menuConfig.showVehicles} onChange={(e) => updateBrandConfig('menuConfig', { showVehicles: e.target.checked })} />
Show Vehicles
</label>
<label>
<span className={LABEL_CLASS}>Offers label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.offersLabel} onChange={(e) => updateBrandConfig('menuConfig', { offersLabel: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.menuConfig.showOffers} onChange={(e) => updateBrandConfig('menuConfig', { showOffers: e.target.checked })} />
Show Offers
</label>
<label>
<span className={LABEL_CLASS}>Pricing label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.pricingLabel} onChange={(e) => updateBrandConfig('menuConfig', { pricingLabel: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.menuConfig.showPricing} onChange={(e) => updateBrandConfig('menuConfig', { showPricing: e.target.checked })} />
Show Pricing
</label>
<label>
<span className={LABEL_CLASS}>Blog label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.blogLabel} onChange={(e) => updateBrandConfig('menuConfig', { blogLabel: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.menuConfig.showBlog} onChange={(e) => updateBrandConfig('menuConfig', { showBlog: e.target.checked })} />
Show Blog
</label>
<label>
<span className={LABEL_CLASS}>Contact label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.contactLabel} onChange={(e) => updateBrandConfig('menuConfig', { contactLabel: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.menuConfig.showContact} onChange={(e) => updateBrandConfig('menuConfig', { showContact: e.target.checked })} />
Show Contact
</label>
<label>
<span className={LABEL_CLASS}>Book car button label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.bookCarLabel} onChange={(e) => updateBrandConfig('menuConfig', { bookCarLabel: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Footer navigation label</span>
<input className={INPUT_CLASS} value={form.brand.menuConfig.siteNavigationLabel} onChange={(e) => updateBrandConfig('menuConfig', { siteNavigationLabel: e.target.value })} />
</label>
</div>
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/60 p-4">
<h4 className="text-sm font-semibold text-zinc-100">Page sections</h4>
<p className="mt-1 text-xs text-zinc-500">Turn sections on or off across every public page.</p>
<div className="mt-4 grid gap-4">
{PAGE_SECTION_GROUPS.map((group) => (
<div key={group.page} className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{group.label}</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{group.sections.map((section) => (
<label key={section.key} className="flex items-center gap-3 text-sm text-zinc-300">
<input
type="checkbox"
checked={getPageSectionValue(group.page, section.key as keyof PublicSitePageSections[typeof group.page])}
onChange={(e) => updatePageSection(group.page, section.key as keyof PublicSitePageSections[typeof group.page], e.target.checked)}
/>
{section.label}
</label>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="mt-8 rounded-[2rem] border border-zinc-800 bg-zinc-950 p-4">
<div className="flex items-center justify-between gap-4 border-b border-zinc-800 px-2 pb-4">
<div>
<h3 className="text-sm font-semibold text-zinc-100">Live homepage preview</h3>
<p className="mt-1 text-xs text-zinc-500">Unsaved changes appear here instantly before you publish them.</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">
Draft preview
</span>
</div>
<div className="mt-4 overflow-hidden rounded-[1.75rem] border border-sky-100 bg-[linear-gradient(180deg,#eff6ff,white_28%)] shadow-2xl shadow-black/20">
<div className="border-b border-slate-200 bg-white/90 px-5 py-4 backdrop-blur-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-sky-600 text-sm font-black text-white">
{previewCompanyName.charAt(0).toUpperCase()}
</span>
<div>
<p className="text-sm font-black tracking-tight text-slate-900">{previewCompanyName}</p>
<p className="text-xs text-slate-500">{form.brand.subdomain || form.company.slug || company.slug}.rentaldrivego.com</p>
</div>
</div>
<nav className="flex flex-wrap items-center gap-2 text-sm text-slate-600">
{previewMenuItems.map((item) => (
<span key={item.label} className="rounded-lg px-3 py-2 transition-colors hover:bg-slate-100 hover:text-slate-900">
{item.label}
</span>
))}
</nav>
<span className="inline-flex rounded-full bg-slate-900 px-5 py-2 text-sm font-semibold text-white">
{previewBookCarLabel}
</span>
</div>
</div>
<div className="px-5 py-8 lg:px-8">
<div className="grid gap-6 lg:hidden">
{homepageLayout.items.map((item) => (
<div key={`mobile-${item.id}`} className="rounded-[1.5rem] border border-slate-200 bg-white p-5 shadow-sm">
{renderHomepagePreviewSection(item.type)}
</div>
))}
</div>
<div
className="relative hidden lg:block"
style={{ height: `${homepageLayout.items.reduce((max, item) => Math.max(max, item.y + item.h - 1), 1) * 72}px` }}
>
{homepageLayout.items.map((item) => (
<div
key={item.id}
className="absolute rounded-[1.75rem] border border-slate-200/80 bg-white/92 p-6 shadow-lg shadow-slate-200/70"
style={{
left: `${((item.x - 1) / 12) * 100}%`,
top: `${(item.y - 1) * 72}px`,
width: `${(item.w / 12) * 100}%`,
height: `${item.h * 72}px`,
}}
>
{renderHomepagePreviewSection(item.type)}
</div>
))}
</div>
</div>
<div className="border-t border-stone-200 bg-white/90 px-5 py-4 backdrop-blur-md">
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">{previewFooterLabel}</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<div className="flex flex-wrap items-center justify-center gap-2 text-sm text-stone-500">
{previewMenuItems.map((item) => (
<span key={`${item.label}-footer`} className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">
{item.label}
</span>
))}
</div>
<p className="text-sm text-stone-500">&copy; {new Date().getFullYear()} {previewCompanyName}</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section className="panel p-6">
<h2 className="text-base font-semibold">Operations and finance</h2>
<div className="mt-4 grid gap-6">
+1 -1
View File
@@ -11,7 +11,7 @@ import {
import { resolveBrowserAppUrl } from '@/lib/appUrls'
function buildUnifiedLoginUrl(nextPath: string) {
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard')
const params = new URLSearchParams({
portal: 'admin',
next: nextPath || '/dashboard',
+1 -1
View File
@@ -5,7 +5,7 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
export default function AdminLoginPage() {
const requestHeaders = headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001',
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
+4 -1
View File
@@ -1,10 +1,13 @@
export function resolveBrowserAppUrl(fallback: string): string {
if (typeof window === 'undefined') return fallback
const h = window.location.hostname
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
try {
const target = new URL(fallback)
target.protocol = window.location.protocol
target.hostname = window.location.hostname
target.hostname = h
target.port = ''
return target.toString().replace(/\/$/, '')
} catch {
return fallback