add homepage management
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
PUBLIC_HOMEPAGE_GRID_COLUMNS,
|
||||
PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
getPublicHomepageSectionLimits,
|
||||
type PublicHomepageLayout,
|
||||
type PublicHomepageLayoutItem,
|
||||
type PublicHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
type Props = {
|
||||
layout: PublicHomepageLayout
|
||||
availableSections: PublicHomepageSectionType[]
|
||||
onChange: (layout: PublicHomepageLayout) => void
|
||||
onAddSection: (type: PublicHomepageSectionType) => void
|
||||
onRemoveSection: (type: PublicHomepageSectionType) => void
|
||||
}
|
||||
|
||||
type Interaction = {
|
||||
itemId: string
|
||||
mode: 'move' | 'resize'
|
||||
startX: number
|
||||
startY: number
|
||||
origin: PublicHomepageLayoutItem
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 52
|
||||
|
||||
const SECTION_META: Record<PublicHomepageSectionType, { label: string; tint: string; description: string }> = {
|
||||
hero: { label: 'Hero', tint: 'from-sky-500 to-cyan-400', description: 'Headline, CTA buttons, and brand media.' },
|
||||
offers: { label: 'Offers', tint: 'from-emerald-500 to-lime-400', description: 'Promotions and campaign cards.' },
|
||||
vehicles: { label: 'Vehicles', tint: 'from-amber-500 to-orange-400', description: 'Published fleet grid.' },
|
||||
pricing: { label: 'Pricing', tint: 'from-fuchsia-500 to-rose-400', description: 'Plans and pricing content.' },
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function HomepageLayoutEditor({ layout, availableSections, onChange, onAddSection, onRemoveSection }: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||
const [interaction, setInteraction] = useState<Interaction | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!interaction) return
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const colWidth = rect.width / PUBLIC_HOMEPAGE_GRID_COLUMNS
|
||||
const deltaCols = Math.round((event.clientX - interaction.startX) / colWidth)
|
||||
const deltaRows = Math.round((event.clientY - interaction.startY) / ROW_HEIGHT)
|
||||
const limits = getPublicHomepageSectionLimits(interaction.origin.type)
|
||||
|
||||
onChange({
|
||||
items: layout.items.map((item) => {
|
||||
if (item.id !== interaction.itemId) return item
|
||||
if (interaction.mode === 'move') {
|
||||
return {
|
||||
...item,
|
||||
x: clamp(interaction.origin.x + deltaCols, 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.w + 1),
|
||||
y: clamp(interaction.origin.y + deltaRows, 1, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.h + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const nextW = clamp(
|
||||
interaction.origin.w + deltaCols,
|
||||
limits.minW,
|
||||
Math.min(limits.maxW, PUBLIC_HOMEPAGE_GRID_COLUMNS - interaction.origin.x + 1),
|
||||
)
|
||||
const nextH = clamp(
|
||||
interaction.origin.h + deltaRows,
|
||||
limits.minH,
|
||||
Math.min(limits.maxH, PUBLIC_HOMEPAGE_GRID_ROWS - interaction.origin.y + 1),
|
||||
)
|
||||
|
||||
return {
|
||||
...item,
|
||||
w: nextW,
|
||||
h: nextH,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
setInteraction(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [interaction, layout.items, onChange])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/70 p-4">
|
||||
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-100">Homepage layout</h4>
|
||||
<p className="mt-1 text-xs text-zinc-500">Add sections, drag them anywhere on the grid, and resize them from the bottom-right handle.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSections.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onAddSection(type)}
|
||||
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-300 transition hover:bg-emerald-500/20"
|
||||
>
|
||||
Add {SECTION_META[type].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative mt-4 hidden overflow-hidden rounded-[1.5rem] border border-zinc-800 bg-zinc-950 lg:block"
|
||||
style={{
|
||||
height: ROW_HEIGHT * PUBLIC_HOMEPAGE_GRID_ROWS,
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${100 / PUBLIC_HOMEPAGE_GRID_COLUMNS}% ${ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="absolute overflow-hidden rounded-[1.25rem] border border-white/10 bg-zinc-900/95 shadow-xl shadow-black/30"
|
||||
style={{
|
||||
left: `${((item.x - 1) / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
top: `${(item.y - 1) * ROW_HEIGHT}px`,
|
||||
width: `${(item.w / PUBLIC_HOMEPAGE_GRID_COLUMNS) * 100}%`,
|
||||
height: `${item.h * ROW_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'move',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className={`flex w-full cursor-grab items-start justify-between bg-gradient-to-r ${meta.tint} px-4 py-3 text-left active:cursor-grabbing`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/75">Drag</p>
|
||||
<p className="mt-1 text-sm font-black text-white">{meta.label}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-black/20 px-2 py-1 text-[11px] font-semibold text-white">
|
||||
{item.w}x{item.h}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex h-[calc(100%-60px)] flex-col justify-between p-4">
|
||||
<p className="max-w-xs text-sm leading-6 text-zinc-300">{meta.description}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">x{item.x} y{item.y}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Resize ${meta.label}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
setInteraction({
|
||||
itemId: item.id,
|
||||
mode: 'resize',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: item,
|
||||
})
|
||||
}}
|
||||
className="absolute bottom-2 right-2 h-7 w-7 rounded-full border border-white/15 bg-white/10 text-white"
|
||||
>
|
||||
<span className="block rotate-45 text-sm">+</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:hidden">
|
||||
{layout.items.map((item) => {
|
||||
const meta = SECTION_META[item.type]
|
||||
return (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-800 bg-zinc-900/70 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">{meta.label}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{meta.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSection(item.type)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-3 py-1 text-xs font-semibold text-rose-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-zinc-500">Desktop drag canvas is available on larger screens.</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,7 @@ export default function AdminCompaniesPage() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'POST',
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user