remove company website pages
This commit is contained in:
+2
-4
@@ -4,11 +4,9 @@ API_PORT=4000
|
|||||||
API_URL=http://localhost:4000
|
API_URL=http://localhost:4000
|
||||||
API_INTERNAL_URL=http://api:4000/api/v1
|
API_INTERNAL_URL=http://api:4000/api/v1
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
|
||||||
NEXT_PUBLIC_MARKETING_URL=http://localhost:3003
|
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3000/marketplace
|
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001/dashboard
|
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3001/dashboard
|
||||||
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002/admin
|
NEXT_PUBLIC_ADMIN_URL=http://localhost:3002/admin
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
|
||||||
DASHBOARD_URL=http://localhost:3001/dashboard
|
DASHBOARD_URL=http://localhost:3001/dashboard
|
||||||
ADMIN_URL=http://localhost:3002/admin
|
ADMIN_URL=http://localhost:3002/admin
|
||||||
JWT_SECRET=dev-secret
|
JWT_SECRET=dev-secret
|
||||||
@@ -21,7 +19,7 @@ ADMIN_SEED_LAST_NAME=Admin
|
|||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||||
CLERK_SECRET_KEY=
|
CLERK_SECRET_KEY=
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003
|
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://localhost:4000
|
||||||
|
|
||||||
# Email - disable Resend (placeholder), use SMTP instead
|
# Email - disable Resend (placeholder), use SMTP instead
|
||||||
RESEND_API_KEY=re_...
|
RESEND_API_KEY=re_...
|
||||||
|
|||||||
@@ -10,17 +10,13 @@ COPY packages ./packages
|
|||||||
|
|
||||||
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ARG NEXT_PUBLIC_MARKETING_URL
|
|
||||||
ARG NEXT_PUBLIC_MARKETPLACE_URL
|
ARG NEXT_PUBLIC_MARKETPLACE_URL
|
||||||
ARG NEXT_PUBLIC_DASHBOARD_URL
|
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||||
ARG NEXT_PUBLIC_ADMIN_URL
|
ARG NEXT_PUBLIC_ADMIN_URL
|
||||||
ARG NEXT_PUBLIC_PUBLIC_SITE_DOMAIN
|
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||||
NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \
|
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
|
NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
|
||||||
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
|
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=$NEXT_PUBLIC_PUBLIC_SITE_DOMAIN
|
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
RUN npm run db:generate
|
RUN npm run db:generate
|
||||||
@@ -39,6 +35,6 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
COPY --from=builder /app/apps ./apps
|
COPY --from=builder /app/apps ./apps
|
||||||
COPY --from=builder /app/packages ./packages
|
COPY --from=builder /app/packages ./packages
|
||||||
|
|
||||||
EXPOSE 3000 4000
|
EXPOSE 3000 3001 3002 4000
|
||||||
|
|
||||||
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||||
"dev": "next dev -p 3003",
|
"dev": "next dev -p 3002",
|
||||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||||
"start": "next start -p 3003",
|
"start": "next start -p 3002",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
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'
|
const API_BASE = '/api/v1'
|
||||||
|
|
||||||
@@ -51,42 +42,6 @@ interface CompanyDetail {
|
|||||||
defaultLocale: string
|
defaultLocale: string
|
||||||
defaultCurrency: string
|
defaultCurrency: string
|
||||||
isListedOnMarketplace: boolean
|
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
|
} | null
|
||||||
contractSettings: {
|
contractSettings: {
|
||||||
legalName: string | null
|
legalName: string | null
|
||||||
@@ -159,42 +114,6 @@ interface FormState {
|
|||||||
defaultLocale: string
|
defaultLocale: string
|
||||||
defaultCurrency: string
|
defaultCurrency: string
|
||||||
isListedOnMarketplace: boolean
|
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: {
|
contractSettings: {
|
||||||
legalName: string
|
legalName: string
|
||||||
@@ -218,8 +137,6 @@ interface FormState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompanyHomePageConfig = NonNullable<CompanyDetail['brand']>['homePageConfig']
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
ACTIVE: 'text-emerald-400',
|
ACTIVE: 'text-emerald-400',
|
||||||
TRIALING: 'text-sky-400',
|
TRIALING: 'text-sky-400',
|
||||||
@@ -229,31 +146,6 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
PAST_DUE: 'text-orange-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 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'
|
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
|
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 {
|
function createFormState(company: CompanyDetail): FormState {
|
||||||
return {
|
return {
|
||||||
company: {
|
company: {
|
||||||
@@ -352,42 +198,6 @@ function createFormState(company: CompanyDetail): FormState {
|
|||||||
defaultLocale: company.brand?.defaultLocale ?? 'en',
|
defaultLocale: company.brand?.defaultLocale ?? 'en',
|
||||||
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
|
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
|
||||||
isListedOnMarketplace: company.brand?.isListedOnMarketplace ?? true,
|
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: {
|
contractSettings: {
|
||||||
legalName: company.contractSettings?.legalName ?? '',
|
legalName: company.contractSettings?.legalName ?? '',
|
||||||
@@ -452,154 +262,6 @@ export default function AdminCompanyDetailPage() {
|
|||||||
setForm((current) => current ? { ...current, [section]: { ...current[section], ...patch } } : current)
|
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() {
|
async function saveChanges() {
|
||||||
if (!form) return
|
if (!form) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -644,42 +306,6 @@ export default function AdminCompanyDetailPage() {
|
|||||||
defaultLocale: form.brand.defaultLocale,
|
defaultLocale: form.brand.defaultLocale,
|
||||||
defaultCurrency: form.brand.defaultCurrency,
|
defaultCurrency: form.brand.defaultCurrency,
|
||||||
isListedOnMarketplace: form.brand.isListedOnMarketplace,
|
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: {
|
contractSettings: {
|
||||||
legalName: toNullable(form.contractSettings.legalName),
|
legalName: toNullable(form.contractSettings.legalName),
|
||||||
@@ -761,158 +387,6 @@ export default function AdminCompanyDetailPage() {
|
|||||||
customers: company._count?.customers ?? company.customers?.length ?? 0,
|
customers: company._count?.customers ?? company.customers?.length ?? 0,
|
||||||
reservations: company._count?.reservations ?? company.reservations?.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 (
|
return (
|
||||||
<div className="shell space-y-6 py-8">
|
<div className="shell space-y-6 py-8">
|
||||||
@@ -1108,253 +582,6 @@ export default function AdminCompanyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">© {new Date().getFullYear()} {previewCompanyName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel p-6">
|
<section className="panel p-6">
|
||||||
<h2 className="text-base font-semibold">Operations and finance</h2>
|
<h2 className="text-base font-semibold">Operations and finance</h2>
|
||||||
<div className="mt-4 grid gap-6">
|
<div className="mt-4 grid gap-6">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
function buildUnifiedLoginUrl(nextPath: string) {
|
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({
|
const params = new URLSearchParams({
|
||||||
portal: 'admin',
|
portal: 'admin',
|
||||||
next: nextPath || '/dashboard',
|
next: nextPath || '/dashboard',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
|
|||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const requestHeaders = headers()
|
const requestHeaders = headers()
|
||||||
const dashboardUrl = resolveServerAppUrl(
|
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('host'),
|
||||||
requestHeaders.get('x-forwarded-proto'),
|
requestHeaders.get('x-forwarded-proto'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export function resolveBrowserAppUrl(fallback: string): string {
|
export function resolveBrowserAppUrl(fallback: string): string {
|
||||||
if (typeof window === 'undefined') return fallback
|
if (typeof window === 'undefined') return fallback
|
||||||
|
const h = window.location.hostname
|
||||||
|
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const target = new URL(fallback)
|
const target = new URL(fallback)
|
||||||
target.protocol = window.location.protocol
|
target.protocol = window.location.protocol
|
||||||
target.hostname = window.location.hostname
|
target.hostname = h
|
||||||
|
target.port = ''
|
||||||
return target.toString().replace(/\/$/, '')
|
return target.toString().replace(/\/$/, '')
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||||
"dev": "next dev -p 3002",
|
"dev": "next dev -p 3001",
|
||||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||||
"start": "next start -p 3002",
|
"start": "next start -p 3001",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function SignInPage() {
|
|||||||
}, [requestedTheme, setTheme, theme])
|
}, [requestedTheme, setTheme, theme])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPath = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
|
const currentPath = window.location.pathname + (window.location.search || '')
|
||||||
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
||||||
}, [pathname, searchParams])
|
}, [pathname, searchParams])
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export function resolveBrowserAppUrl(fallback: string): string {
|
export function resolveBrowserAppUrl(fallback: string): string {
|
||||||
if (typeof window === 'undefined') return fallback
|
if (typeof window === 'undefined') return fallback
|
||||||
|
const h = window.location.hostname
|
||||||
|
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const target = new URL(fallback)
|
const target = new URL(fallback)
|
||||||
target.protocol = window.location.protocol
|
target.protocol = window.location.protocol
|
||||||
target.hostname = window.location.hostname
|
target.hostname = h
|
||||||
|
target.port = ''
|
||||||
return target.toString().replace(/\/$/, '')
|
return target.toString().replace(/\/$/, '')
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function toAppBase(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const marketplaceUrl = toAppBase(
|
export const marketplaceUrl = toAppBase(
|
||||||
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000/marketplace'),
|
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const adminUrl = toAppBase(
|
export const adminUrl = toAppBase(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
basePath: '/marketplace',
|
|
||||||
images: {
|
images: {
|
||||||
domains: ['res.cloudinary.com'],
|
domains: ['res.cloudinary.com'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
"predev": "npm run build --workspace @rentaldrivego/types",
|
||||||
"dev": "next dev -p 3001",
|
"dev": "next dev -p 3000",
|
||||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
"prestart": "npm run build --workspace @rentaldrivego/types",
|
||||||
"start": "next start -p 3001",
|
"start": "next start -p 3000",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function HomeContent() {
|
|||||||
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||||
const dict = content[language]
|
const dict = content[language]
|
||||||
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
||||||
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 starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function BrandedWebsitePage() {
|
|
||||||
redirect('/explore')
|
|
||||||
}
|
|
||||||
@@ -125,7 +125,7 @@ export default function PricingClient() {
|
|||||||
const dict = copy[language]
|
const dict = copy[language]
|
||||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||||
const [billing, setBilling] = useState<Billing>('monthly')
|
const [billing, setBilling] = useState<Billing>('monthly')
|
||||||
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 savings = annualSavingsPct('STARTER', currency) // same % for all plans
|
const savings = annualSavingsPct('STARTER', currency) // same % for all plans
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
|
|||||||
export default function RenterSignInPage() {
|
export default function RenterSignInPage() {
|
||||||
const requestHeaders = headers()
|
const requestHeaders = headers()
|
||||||
const dashboardUrl = resolveServerAppUrl(
|
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('host'),
|
||||||
requestHeaders.get('x-forwarded-proto'),
|
requestHeaders.get('x-forwarded-proto'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
|
|||||||
export default function RenterSignUpPage() {
|
export default function RenterSignUpPage() {
|
||||||
const requestHeaders = headers()
|
const requestHeaders = headers()
|
||||||
const dashboardUrl = resolveServerAppUrl(
|
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('host'),
|
||||||
requestHeaders.get('x-forwarded-proto'),
|
requestHeaders.get('x-forwarded-proto'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -198,8 +198,8 @@ export function useMarketplacePreferences() {
|
|||||||
export default function MarketplaceShell({ children }: { children: React.ReactNode }) {
|
export default function MarketplaceShell({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
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 adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002')
|
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002/admin')
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||||
const [language, setLanguage] = useState<MarketplaceLanguage>('en')
|
const [language, setLanguage] = useState<MarketplaceLanguage>('en')
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
const [theme, setTheme] = useState<Theme>('light')
|
||||||
@@ -331,7 +331,7 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
|||||||
const footerContent = getFooterContent(language)
|
const footerContent = getFooterContent(language)
|
||||||
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
|
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
|
||||||
const isRenterApp = pathname.startsWith('/renter/')
|
const isRenterApp = pathname.startsWith('/renter/')
|
||||||
const isEmbeddedWorkspace = pathname === '/branded-website' || isRenterApp
|
const isEmbeddedWorkspace = isRenterApp
|
||||||
const hideHeader = isRenterApp
|
const hideHeader = isRenterApp
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ language, theme, dict, setLanguage, setTheme }),
|
() => ({ language, theme, dict, setLanguage, setTheme }),
|
||||||
|
|||||||
@@ -3,18 +3,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMarketplacePreferences } from './MarketplaceShell'
|
import { useMarketplacePreferences } from './MarketplaceShell'
|
||||||
|
|
||||||
type FrameId = 'sign-in' | 'public-site'
|
type FrameId = 'sign-in'
|
||||||
|
|
||||||
const frameConfig: Record<FrameId, { label: string; port: number; path: string }> = {
|
const frameConfig: Record<FrameId, { label: string; port: number; path: string }> = {
|
||||||
'sign-in': {
|
'sign-in': {
|
||||||
label: 'Sign in',
|
label: 'Sign in',
|
||||||
port: 3001,
|
port: 3001,
|
||||||
path: '/sign-in',
|
path: '/dashboard/sign-in',
|
||||||
},
|
|
||||||
'public-site': {
|
|
||||||
label: 'Branded Website',
|
|
||||||
port: 3003,
|
|
||||||
path: '/',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +24,7 @@ function getDefaultFramePath(target: FrameId) {
|
|||||||
.find((c) => c.startsWith('employee_token='))
|
.find((c) => c.startsWith('employee_token='))
|
||||||
|
|
||||||
if (target === 'sign-in' && employeeToken) {
|
if (target === 'sign-in' && employeeToken) {
|
||||||
return '/dashboard'
|
return '/dashboard/dashboard'
|
||||||
}
|
}
|
||||||
|
|
||||||
return frameConfig[target].path
|
return frameConfig[target].path
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
|||||||
|
|
||||||
export default function WorkspaceTabs() {
|
export default function WorkspaceTabs() {
|
||||||
const { language, theme } = useMarketplacePreferences()
|
const { language, theme } = useMarketplacePreferences()
|
||||||
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 copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export function resolveBrowserAppUrl(fallback: string): string {
|
export function resolveBrowserAppUrl(fallback: string): string {
|
||||||
if (typeof window === 'undefined') return fallback
|
if (typeof window === 'undefined') return fallback
|
||||||
|
const h = window.location.hostname
|
||||||
|
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const target = new URL(fallback)
|
const target = new URL(fallback)
|
||||||
target.protocol = window.location.protocol
|
target.protocol = window.location.protocol
|
||||||
target.hostname = window.location.hostname
|
target.hostname = h
|
||||||
|
target.port = ''
|
||||||
return target.toString().replace(/\/$/, '')
|
return target.toString().replace(/\/$/, '')
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
typescript: {
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
domains: ['res.cloudinary.com'],
|
|
||||||
},
|
|
||||||
transpilePackages: ['@rentaldrivego/types'],
|
|
||||||
async rewrites() {
|
|
||||||
return [
|
|
||||||
{ source: '/marketplace', destination: 'http://localhost:3001/marketplace' },
|
|
||||||
{ source: '/marketplace/:path*', destination: 'http://localhost:3001/marketplace/:path*' },
|
|
||||||
{ source: '/dashboard', destination: 'http://localhost:3002/dashboard' },
|
|
||||||
{ source: '/dashboard/:path*', destination: 'http://localhost:3002/dashboard/:path*' },
|
|
||||||
{ source: '/admin', destination: 'http://localhost:3003/admin' },
|
|
||||||
{ source: '/admin/:path*', destination: 'http://localhost:3003/admin/:path*' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@rentaldrivego/public-site",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"predev": "npm run build --workspace @rentaldrivego/types",
|
|
||||||
"dev": "next dev -p 3000",
|
|
||||||
"prebuild": "npm run build --workspace @rentaldrivego/types",
|
|
||||||
"build": "next build",
|
|
||||||
"prestart": "npm run build --workspace @rentaldrivego/types",
|
|
||||||
"start": "next start -p 3000",
|
|
||||||
"type-check": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@rentaldrivego/types": "*",
|
|
||||||
"next": "14.2.3",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"autoprefixer": "^10.4.19",
|
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"zod": "^3.23.0",
|
|
||||||
"dayjs": "^1.11.11",
|
|
||||||
"lucide-react": "^0.376.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.12.0",
|
|
||||||
"@types/react": "^18.3.1",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"typescript": "^5.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,96 +0,0 @@
|
|||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
company: { slug: string; name: string }
|
|
||||||
brand: {
|
|
||||||
displayName: string
|
|
||||||
tagline: string | null
|
|
||||||
heroImageUrl: string | null
|
|
||||||
publicPhone: string | null
|
|
||||||
publicEmail: string | null
|
|
||||||
publicCity: string | null
|
|
||||||
publicCountry: string | null
|
|
||||||
publicAddress: string | null
|
|
||||||
whatsappNumber: string | null
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AboutPage() {
|
|
||||||
const language = getPublicSiteLanguage()
|
|
||||||
const dict = getPublicSiteDictionary(language)
|
|
||||||
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
|
|
||||||
const companyName = brand?.brand?.displayName ?? brand?.company.name ?? dict.rentalCompanyFallback
|
|
||||||
const location = [brand?.brand?.publicCity, brand?.brand?.publicCountry].filter(Boolean).join(', ')
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-16">
|
|
||||||
<div className="shell space-y-10">
|
|
||||||
{pageSections.about.hero ? <section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.about.eyebrow}</p>
|
|
||||||
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{companyName}</h1>
|
|
||||||
<p className="mt-5 text-lg leading-8 text-slate-600">
|
|
||||||
{brand?.brand?.tagline ?? dict.about.defaultTagline}
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
|
||||||
<InfoCard label={dict.about.location} value={location || dict.about.locationMissing} />
|
|
||||||
<InfoCard label={dict.about.phone} value={brand?.brand?.publicPhone || dict.about.phoneMissing} />
|
|
||||||
<InfoCard label={dict.about.email} value={brand?.brand?.publicEmail || dict.about.emailMissing} />
|
|
||||||
<InfoCard label={dict.about.whatsapp} value={brand?.brand?.whatsappNumber || dict.about.whatsappMissing} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card overflow-hidden bg-slate-100 min-h-80">
|
|
||||||
{brand?.brand?.heroImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={brand.brand.heroImageUrl} alt={companyName} className="h-full w-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center p-8 text-center text-slate-500">
|
|
||||||
{dict.about.heroImageMissing}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section> : null}
|
|
||||||
|
|
||||||
{pageSections.about.highlights ? <section className="grid gap-6 md:grid-cols-3">
|
|
||||||
<article className="card p-6">
|
|
||||||
<h2 className="text-xl font-black text-slate-900">{dict.about.directBookingTitle}</h2>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.directBookingBody}</p>
|
|
||||||
</article>
|
|
||||||
<article className="card p-6">
|
|
||||||
<h2 className="text-xl font-black text-slate-900">{dict.about.publishedFleetTitle}</h2>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.publishedFleetBody}</p>
|
|
||||||
</article>
|
|
||||||
<article className="card p-6">
|
|
||||||
<h2 className="text-xl font-black text-slate-900">{dict.about.marketplaceReadyTitle}</h2>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-600">{dict.about.marketplaceReadyBody}</p>
|
|
||||||
</article>
|
|
||||||
</section> : null}
|
|
||||||
|
|
||||||
{pageSections.about.details ? <section className="card p-8">
|
|
||||||
<h2 className="text-2xl font-black text-slate-900">{dict.about.companyDetails}</h2>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-600">
|
|
||||||
{brand?.brand?.publicAddress ?? dict.about.companyAddressMissing}
|
|
||||||
</p>
|
|
||||||
</section> : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="card p-5">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">{label}</p>
|
|
||||||
<p className="mt-2 text-sm font-medium text-slate-900">{value}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogPage() {
|
|
||||||
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
|
|
||||||
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[linear-gradient(180deg,#fff, #f8fafc_35%)] py-16">
|
|
||||||
<div className="shell space-y-10">
|
|
||||||
{pageSections.blog.hero ? <section className="max-w-3xl">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.blog.eyebrow}</p>
|
|
||||||
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.blog.title}</h1>
|
|
||||||
<p className="mt-5 text-lg leading-8 text-slate-600">
|
|
||||||
{dict.blog.description}
|
|
||||||
</p>
|
|
||||||
</section> : null}
|
|
||||||
|
|
||||||
{pageSections.blog.posts ? <section className="grid gap-6 lg:grid-cols-3">
|
|
||||||
{dict.blog.posts.map((post) => (
|
|
||||||
<article key={post.title} className="card p-7">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700">{post.category}</p>
|
|
||||||
<h2 className="mt-4 text-2xl font-black tracking-tight text-slate-900">{post.title}</h2>
|
|
||||||
<p className="mt-4 text-sm leading-7 text-slate-600">{post.excerpt}</p>
|
|
||||||
<p className="mt-6 text-sm font-semibold text-slate-900">{dict.blog.draftPlaceholder}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section> : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,789 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { DEFAULT_COMPANY_SLUG } from '@/lib/api'
|
|
||||||
import { getPublicSiteDictionary, type PublicSiteLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface VehicleDetail {
|
|
||||||
id: string
|
|
||||||
make: string
|
|
||||||
model: string
|
|
||||||
year: number
|
|
||||||
category: string
|
|
||||||
dailyRate: number
|
|
||||||
seats: number
|
|
||||||
transmission: string
|
|
||||||
photos: string[]
|
|
||||||
availability: boolean
|
|
||||||
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
|
||||||
nextAvailableAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PromoOffer {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
discountValue: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InsurancePolicy {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL'
|
|
||||||
chargeValue: number
|
|
||||||
isRequired: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContractSettings {
|
|
||||||
fuelPolicyType: string
|
|
||||||
fuelPolicyNote: string | null
|
|
||||||
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
|
|
||||||
additionalDriverDailyRate: number
|
|
||||||
additionalDriverFlatRate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookingOptions {
|
|
||||||
insurancePolicies: InsurancePolicy[]
|
|
||||||
contractSettings: ContractSettings | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Step1 {
|
|
||||||
startDate: string
|
|
||||||
startTime: string
|
|
||||||
endDate: string
|
|
||||||
endTime: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Step2 {
|
|
||||||
firstName: string
|
|
||||||
lastName: string
|
|
||||||
email: string
|
|
||||||
phone: string
|
|
||||||
driverLicense: string
|
|
||||||
dateOfBirth: string
|
|
||||||
licenseExpiry: string
|
|
||||||
licenseIssuedAt: string
|
|
||||||
nationality: string
|
|
||||||
promoCode: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdditionalDriverForm {
|
|
||||||
firstName: string
|
|
||||||
lastName: string
|
|
||||||
email: string
|
|
||||||
phone: string
|
|
||||||
driverLicense: string
|
|
||||||
dateOfBirth: string
|
|
||||||
licenseExpiry: string
|
|
||||||
licenseIssuedAt: string
|
|
||||||
nationality: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
|
||||||
|
|
||||||
function diffDays(start: string, end: string): number {
|
|
||||||
if (!start || !end) return 0
|
|
||||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
|
||||||
return Math.max(1, Math.ceil(ms / 86400000))
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmt(n: number) {
|
|
||||||
return n.toLocaleString('fr-MA', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputClass(error?: string) {
|
|
||||||
return [
|
|
||||||
'w-full rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500',
|
|
||||||
error ? 'border-red-400 bg-red-50' : 'border-slate-300 bg-white',
|
|
||||||
].join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAvailabilityDate(value?: string | null) {
|
|
||||||
if (!value) return null
|
|
||||||
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function insuranceCharge(policy: InsurancePolicy, days: number, baseTotal: number) {
|
|
||||||
if (policy.chargeType === 'PER_DAY') return policy.chargeValue * days
|
|
||||||
if (policy.chargeType === 'PERCENTAGE_OF_RENTAL') return Math.round(baseTotal * policy.chargeValue / 100)
|
|
||||||
return policy.chargeValue
|
|
||||||
}
|
|
||||||
|
|
||||||
function additionalDriverCharge(settings: ContractSettings | null, count: number, days: number) {
|
|
||||||
if (!settings || count === 0) return 0
|
|
||||||
if (settings.additionalDriverCharge === 'PER_DAY') return settings.additionalDriverDailyRate * count * days
|
|
||||||
if (settings.additionalDriverCharge === 'FLAT') return settings.additionalDriverFlatRate * count
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDayLabel(language: PublicSiteLanguage, days: number) {
|
|
||||||
if (language === 'fr') return days === 1 ? 'jour' : 'jours'
|
|
||||||
if (language === 'ar') return days === 1 ? 'يوم' : 'أيام'
|
|
||||||
return days === 1 ? 'day' : 'days'
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepDots({
|
|
||||||
step,
|
|
||||||
steps,
|
|
||||||
}: {
|
|
||||||
step: number
|
|
||||||
steps: {
|
|
||||||
selectDates: string
|
|
||||||
driverDetails: string
|
|
||||||
reviewBooking: string
|
|
||||||
payment: string
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="mb-6 flex items-center gap-2">
|
|
||||||
{[1, 2, 3, 4].map((n) => (
|
|
||||||
<div key={n} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold',
|
|
||||||
n === step ? 'bg-slate-900 text-white' : n < step ? 'bg-sky-600 text-white' : 'bg-slate-200 text-slate-500',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{n < step ? '✓' : n}
|
|
||||||
</div>
|
|
||||||
{n < 4 && <div className={['h-0.5 w-8 rounded', n < step ? 'bg-sky-600' : 'bg-slate-200'].join(' ')} />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<span className="ml-2 text-sm text-slate-500">
|
|
||||||
{step === 1 && steps.selectDates}
|
|
||||||
{step === 2 && steps.driverDetails}
|
|
||||||
{step === 3 && steps.reviewBooking}
|
|
||||||
{step === 4 && steps.payment}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SummaryRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueClass = 'text-slate-900',
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
valueClass?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between gap-4 px-4 py-3">
|
|
||||||
<span className="flex-shrink-0 text-slate-500">{label}</span>
|
|
||||||
<span className={`text-right font-medium ${valueClass}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BookClient({ language }: { language: PublicSiteLanguage }) {
|
|
||||||
const dict = getPublicSiteDictionary(language)
|
|
||||||
const params = useSearchParams()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const vehicleId = params.get('vehicleId') ?? ''
|
|
||||||
const offerId = params.get('offerId') ?? undefined
|
|
||||||
const ref = params.get('ref') ?? 'direct'
|
|
||||||
const slug = params.get('slug') ?? DEFAULT_COMPANY_SLUG
|
|
||||||
|
|
||||||
const [step, setStep] = useState(1)
|
|
||||||
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
|
||||||
const [vehicleLoading, setVehicleLoading] = useState(!!vehicleId)
|
|
||||||
const [vehicleError, setVehicleError] = useState('')
|
|
||||||
const [bookingOptions, setBookingOptions] = useState<BookingOptions | null>(null)
|
|
||||||
const [bookingOptionsError, setBookingOptionsError] = useState('')
|
|
||||||
|
|
||||||
const [step1, setStep1] = useState<Step1>({ startDate: '', startTime: '10:00', endDate: '', endTime: '10:00' })
|
|
||||||
const [step1Errors, setStep1Errors] = useState<Partial<Step1>>({})
|
|
||||||
const [step2, setStep2] = useState<Step2>({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
driverLicense: '',
|
|
||||||
dateOfBirth: '',
|
|
||||||
licenseExpiry: '',
|
|
||||||
licenseIssuedAt: '',
|
|
||||||
nationality: '',
|
|
||||||
promoCode: '',
|
|
||||||
})
|
|
||||||
const [step2Errors, setStep2Errors] = useState<Record<string, string>>({})
|
|
||||||
const [additionalDriverEnabled, setAdditionalDriverEnabled] = useState(false)
|
|
||||||
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
driverLicense: '',
|
|
||||||
dateOfBirth: '',
|
|
||||||
licenseExpiry: '',
|
|
||||||
licenseIssuedAt: '',
|
|
||||||
nationality: '',
|
|
||||||
})
|
|
||||||
const [selectedInsurancePolicyIds, setSelectedInsurancePolicyIds] = useState<string[]>([])
|
|
||||||
const [promoApplied, setPromoApplied] = useState<PromoOffer | null>(null)
|
|
||||||
const [promoLoading, setPromoLoading] = useState(false)
|
|
||||||
const [promoError, setPromoError] = useState('')
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [submitError, setSubmitError] = useState('')
|
|
||||||
const [reservationId, setReservationId] = useState<string | null>(null)
|
|
||||||
const [requiresManualApproval, setRequiresManualApproval] = useState(false)
|
|
||||||
|
|
||||||
const [paymentProvider, setPaymentProvider] = useState<'AMANPAY' | 'PAYPAL' | 'CASH'>('AMANPAY')
|
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD')
|
|
||||||
const [paymentLoading, setPaymentLoading] = useState(false)
|
|
||||||
const [paymentError, setPaymentError] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!vehicleId) return
|
|
||||||
setVehicleLoading(true)
|
|
||||||
fetch(`${API_BASE}/site/${slug}/vehicles/${vehicleId}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((j) => {
|
|
||||||
if (j?.data) setVehicle(j.data as VehicleDetail)
|
|
||||||
else setVehicleError(dict.booking.vehicleNotFound)
|
|
||||||
})
|
|
||||||
.catch(() => setVehicleError(dict.booking.vehicleLoadError))
|
|
||||||
.finally(() => setVehicleLoading(false))
|
|
||||||
}, [dict.booking.vehicleLoadError, dict.booking.vehicleNotFound, vehicleId, slug])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!vehicle?.nextAvailableAt) return
|
|
||||||
const minStart = new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10)
|
|
||||||
const minEnd = new Date(new Date(minStart).getTime() + 86400000).toISOString().slice(0, 10)
|
|
||||||
|
|
||||||
setStep1((current) => {
|
|
||||||
const nextStart = !current.startDate || current.startDate < minStart ? minStart : current.startDate
|
|
||||||
const nextEnd = !current.endDate || current.endDate <= nextStart ? minEnd : current.endDate
|
|
||||||
if (nextStart === current.startDate && nextEnd === current.endDate) return current
|
|
||||||
return { ...current, startDate: nextStart, endDate: nextEnd }
|
|
||||||
})
|
|
||||||
}, [vehicle?.nextAvailableAt])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${API_BASE}/site/${slug}/booking-options`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((j) => setBookingOptions(j.data as BookingOptions))
|
|
||||||
.catch(() => setBookingOptionsError(dict.booking.bookingOptionsLoadError))
|
|
||||||
}, [dict.booking.bookingOptionsLoadError, slug])
|
|
||||||
|
|
||||||
const days = diffDays(step1.startDate, step1.endDate)
|
|
||||||
const baseTotal = vehicle ? vehicle.dailyRate * days : 0
|
|
||||||
const discount = promoApplied ? Math.round(baseTotal * (promoApplied.discountValue / 100)) : 0
|
|
||||||
const requiredPolicies = (bookingOptions?.insurancePolicies ?? []).filter((policy) => policy.isRequired)
|
|
||||||
const optionalPolicies = (bookingOptions?.insurancePolicies ?? []).filter((policy) => !policy.isRequired)
|
|
||||||
const selectedPolicies = [
|
|
||||||
...requiredPolicies,
|
|
||||||
...optionalPolicies.filter((policy) => selectedInsurancePolicyIds.includes(policy.id)),
|
|
||||||
]
|
|
||||||
const insuranceTotal = selectedPolicies.reduce((sum, policy) => sum + insuranceCharge(policy, days, baseTotal), 0)
|
|
||||||
const additionalDriverTotal = additionalDriverCharge(bookingOptions?.contractSettings ?? null, additionalDriverEnabled ? 1 : 0, days)
|
|
||||||
const grandTotal = baseTotal - discount + insuranceTotal + additionalDriverTotal
|
|
||||||
const dayLabel = getDayLabel(language, days)
|
|
||||||
|
|
||||||
function validateStep1() {
|
|
||||||
const errs: Partial<Step1> = {}
|
|
||||||
const minStart = vehicle?.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : ''
|
|
||||||
if (!step1.startDate) errs.startDate = dict.booking.validation.pickStartDate
|
|
||||||
if (!step1.endDate) errs.endDate = dict.booking.validation.pickEndDate
|
|
||||||
if (minStart && step1.startDate && step1.startDate < minStart) errs.startDate = dict.booking.vehicleAvailableFrom(formatAvailabilityDate(vehicle?.nextAvailableAt) ?? minStart)
|
|
||||||
if (step1.startDate && step1.endDate && new Date(step1.endDate) <= new Date(step1.startDate)) errs.endDate = dict.booking.validation.endDateAfterStart
|
|
||||||
setStep1Errors(errs)
|
|
||||||
return Object.keys(errs).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateStep2() {
|
|
||||||
const errs: Record<string, string> = {}
|
|
||||||
if (!step2.firstName.trim()) errs.firstName = dict.booking.validation.firstNameRequired
|
|
||||||
if (!step2.lastName.trim()) errs.lastName = dict.booking.validation.lastNameRequired
|
|
||||||
if (!step2.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(step2.email)) errs.email = dict.booking.validation.validEmailRequired
|
|
||||||
if (!step2.driverLicense.trim()) errs.driverLicense = dict.booking.validation.driverLicenseRequired
|
|
||||||
if (!step2.dateOfBirth) errs.dateOfBirth = dict.booking.validation.dateOfBirthRequired
|
|
||||||
if (!step2.licenseExpiry) errs.licenseExpiry = dict.booking.validation.licenseExpiryRequired
|
|
||||||
if (!step2.licenseIssuedAt) errs.licenseIssuedAt = dict.booking.validation.licenseIssueDateRequired
|
|
||||||
if (additionalDriverEnabled) {
|
|
||||||
if (!additionalDriver.firstName.trim()) errs.additionalDriverFirstName = dict.booking.validation.additionalDriverFirstNameRequired
|
|
||||||
if (!additionalDriver.lastName.trim()) errs.additionalDriverLastName = dict.booking.validation.additionalDriverLastNameRequired
|
|
||||||
if (!additionalDriver.driverLicense.trim()) errs.additionalDriverLicense = dict.booking.validation.additionalDriverLicenseRequired
|
|
||||||
}
|
|
||||||
setStep2Errors(errs)
|
|
||||||
return Object.keys(errs).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyPromo() {
|
|
||||||
if (!step2.promoCode.trim()) return
|
|
||||||
setPromoLoading(true)
|
|
||||||
setPromoError('')
|
|
||||||
setPromoApplied(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/site/${slug}/book/validate-code`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ code: step2.promoCode.trim() }),
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (!res.ok) setPromoError(json?.message ?? dict.booking.promo.invalid)
|
|
||||||
else setPromoApplied(json.data as PromoOffer)
|
|
||||||
} catch {
|
|
||||||
setPromoError(dict.booking.promo.unavailable)
|
|
||||||
} finally {
|
|
||||||
setPromoLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
setSubmitting(true)
|
|
||||||
setSubmitError('')
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/site/${slug}/book`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
vehicleId,
|
|
||||||
offerId: promoApplied?.id ?? offerId,
|
|
||||||
promoCodeUsed: promoApplied ? step2.promoCode.trim() : undefined,
|
|
||||||
source: ref === 'marketplace' ? 'MARKETPLACE' : 'PUBLIC_SITE',
|
|
||||||
startDate: new Date(step1.startDate).toISOString(),
|
|
||||||
endDate: new Date(step1.endDate).toISOString(),
|
|
||||||
firstName: step2.firstName.trim(),
|
|
||||||
lastName: step2.lastName.trim(),
|
|
||||||
email: step2.email.trim(),
|
|
||||||
phone: step2.phone.trim() || undefined,
|
|
||||||
driverLicense: step2.driverLicense.trim(),
|
|
||||||
dateOfBirth: new Date(step2.dateOfBirth).toISOString(),
|
|
||||||
licenseExpiry: new Date(step2.licenseExpiry).toISOString(),
|
|
||||||
licenseIssuedAt: new Date(step2.licenseIssuedAt).toISOString(),
|
|
||||||
nationality: step2.nationality.trim() || undefined,
|
|
||||||
selectedInsurancePolicyIds,
|
|
||||||
additionalDrivers: additionalDriverEnabled ? [{
|
|
||||||
firstName: additionalDriver.firstName.trim(),
|
|
||||||
lastName: additionalDriver.lastName.trim(),
|
|
||||||
email: additionalDriver.email.trim() || undefined,
|
|
||||||
phone: additionalDriver.phone.trim() || undefined,
|
|
||||||
driverLicense: additionalDriver.driverLicense.trim(),
|
|
||||||
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
|
|
||||||
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
|
|
||||||
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
|
|
||||||
nationality: additionalDriver.nationality.trim() || undefined,
|
|
||||||
}] : [],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
setSubmitError(json?.nextAvailableAt ? dict.booking.vehicleAvailableFrom(formatAvailabilityDate(json.nextAvailableAt) ?? json.nextAvailableAt) : (json?.message ?? dict.booking.submit.failed))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setReservationId(json.data.id)
|
|
||||||
setRequiresManualApproval(Boolean(json.data.requiresManualApproval))
|
|
||||||
setStep(4)
|
|
||||||
if (json.data.requiresManualApproval) setPaymentProvider('CASH')
|
|
||||||
} catch {
|
|
||||||
setSubmitError(dict.booking.submit.network)
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePayOnline() {
|
|
||||||
if (!reservationId) return
|
|
||||||
setPaymentLoading(true)
|
|
||||||
setPaymentError('')
|
|
||||||
try {
|
|
||||||
const base = window.location.origin
|
|
||||||
const res = await fetch(`${API_BASE}/site/${slug}/booking/${reservationId}/pay`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
provider: paymentProvider,
|
|
||||||
currency: paymentCurrency,
|
|
||||||
successUrl: `${base}/book/confirmation?id=${reservationId}&payment=success`,
|
|
||||||
failureUrl: `${base}/book?vehicleId=${vehicleId}&payment=failed`,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (!res.ok) {
|
|
||||||
setPaymentError(json?.message ?? dict.booking.payment.initiateFailed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.location.href = json.data.checkoutUrl
|
|
||||||
} catch {
|
|
||||||
setPaymentError(dict.booking.payment.network)
|
|
||||||
} finally {
|
|
||||||
setPaymentLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePayAtPickup() {
|
|
||||||
if (!reservationId) return
|
|
||||||
router.push(`/book/confirmation?id=${reservationId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card p-8">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.booking.eyebrow}</p>
|
|
||||||
<h1 className="mt-2 text-3xl font-black text-slate-900">{dict.booking.title}</h1>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<StepDots step={step} steps={dict.booking.steps} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{vehicleLoading && <div className="mb-6 rounded-xl bg-slate-100 px-4 py-3 text-sm text-slate-500">{dict.booking.loadingVehicle}</div>}
|
|
||||||
{vehicleError && <div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{vehicleError}</div>}
|
|
||||||
{vehicle && (
|
|
||||||
<div className="mb-6 flex items-center gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
|
||||||
{vehicle.photos[0] && (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-16 w-24 rounded-lg object-cover flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-slate-900">
|
|
||||||
{vehicle.make} {vehicle.model} <span className="font-normal text-slate-500">({vehicle.year})</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-slate-500">{vehicle.category} · {vehicle.transmission} · {dict.vehicles.seats(vehicle.seats)}</p>
|
|
||||||
<p className="mt-0.5 text-sm font-semibold text-slate-800">{fmt(vehicle.dailyRate)} {dict.vehicles.detail.perDay}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{vehicle && !vehicle.availability && (
|
|
||||||
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
||||||
{vehicle.nextAvailableAt ? dict.booking.vehicleAvailableFrom(formatAvailabilityDate(vehicle.nextAvailableAt) ?? vehicle.nextAvailableAt) : dict.booking.vehicleUnavailableNoDate}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.startDate}</label>
|
|
||||||
<input type="date" value={step1.startDate} min={vehicle?.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10)} onChange={(e) => setStep1((s) => ({ ...s, startDate: e.target.value, endDate: !s.endDate || s.endDate <= e.target.value ? new Date(new Date(e.target.value).getTime() + 86400000).toISOString().slice(0, 10) : s.endDate }))} className={inputClass(step1Errors.startDate)} />
|
|
||||||
{step1Errors.startDate && <p className="mt-1 text-xs text-red-600">{step1Errors.startDate}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.endDate}</label>
|
|
||||||
<input type="date" value={step1.endDate} min={step1.startDate || new Date().toISOString().slice(0, 10)} onChange={(e) => setStep1((s) => ({ ...s, endDate: e.target.value }))} className={inputClass(step1Errors.endDate)} />
|
|
||||||
{step1Errors.endDate && <p className="mt-1 text-xs text-red-600">{step1Errors.endDate}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{days > 0 && vehicle && (
|
|
||||||
<div className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">
|
|
||||||
<span className="font-semibold">{days} {dayLabel}</span> × {fmt(vehicle.dailyRate)} = <span className="font-bold">{fmt(baseTotal)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button onClick={() => validateStep1() && setStep(2)} className="w-full rounded-full bg-slate-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700">
|
|
||||||
{dict.booking.continueToDriverDetails}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.firstName}</label>
|
|
||||||
<input value={step2.firstName} onChange={(e) => setStep2((s) => ({ ...s, firstName: e.target.value }))} placeholder={dict.booking.firstName} className={inputClass(step2Errors.firstName)} />
|
|
||||||
{step2Errors.firstName && <p className="mt-1 text-xs text-red-600">{step2Errors.firstName}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.lastName}</label>
|
|
||||||
<input value={step2.lastName} onChange={(e) => setStep2((s) => ({ ...s, lastName: e.target.value }))} placeholder={dict.booking.lastName} className={inputClass(step2Errors.lastName)} />
|
|
||||||
{step2Errors.lastName && <p className="mt-1 text-xs text-red-600">{step2Errors.lastName}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.email}</label>
|
|
||||||
<input type="email" value={step2.email} onChange={(e) => setStep2((s) => ({ ...s, email: e.target.value }))} placeholder="you@example.com" className={inputClass(step2Errors.email)} />
|
|
||||||
{step2Errors.email && <p className="mt-1 text-xs text-red-600">{step2Errors.email}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.phone}</label>
|
|
||||||
<input value={step2.phone} onChange={(e) => setStep2((s) => ({ ...s, phone: e.target.value }))} placeholder="+212 6xx xxx xxx" className={inputClass()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.driverLicense}</label>
|
|
||||||
<input value={step2.driverLicense} onChange={(e) => setStep2((s) => ({ ...s, driverLicense: e.target.value }))} placeholder={dict.booking.driverLicense} className={inputClass(step2Errors.driverLicense)} />
|
|
||||||
{step2Errors.driverLicense && <p className="mt-1 text-xs text-red-600">{step2Errors.driverLicense}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.nationality}</label>
|
|
||||||
<input value={step2.nationality} onChange={(e) => setStep2((s) => ({ ...s, nationality: e.target.value }))} placeholder={dict.booking.nationality} className={inputClass()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.dateOfBirth}</label>
|
|
||||||
<input type="date" value={step2.dateOfBirth} onChange={(e) => setStep2((s) => ({ ...s, dateOfBirth: e.target.value }))} className={inputClass(step2Errors.dateOfBirth)} />
|
|
||||||
{step2Errors.dateOfBirth && <p className="mt-1 text-xs text-red-600">{step2Errors.dateOfBirth}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.licenseIssued}</label>
|
|
||||||
<input type="date" value={step2.licenseIssuedAt} onChange={(e) => setStep2((s) => ({ ...s, licenseIssuedAt: e.target.value }))} className={inputClass(step2Errors.licenseIssuedAt)} />
|
|
||||||
{step2Errors.licenseIssuedAt && <p className="mt-1 text-xs text-red-600">{step2Errors.licenseIssuedAt}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.licenseExpiry}</label>
|
|
||||||
<input type="date" value={step2.licenseExpiry} onChange={(e) => setStep2((s) => ({ ...s, licenseExpiry: e.target.value }))} className={inputClass(step2Errors.licenseExpiry)} />
|
|
||||||
{step2Errors.licenseExpiry && <p className="mt-1 text-xs text-red-600">{step2Errors.licenseExpiry}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{bookingOptionsError && <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{bookingOptionsError}</div>}
|
|
||||||
|
|
||||||
{bookingOptions && (
|
|
||||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{dict.booking.insuranceAndPickupPolicies}</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
{bookingOptions.contractSettings?.fuelPolicyNote || dict.booking.insuranceHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedPolicies.length > 0 && (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-600">
|
|
||||||
{dict.booking.requiredCoverage}: {requiredPolicies.map((policy) => policy.name).join(', ') || dict.booking.none}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{optionalPolicies.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{optionalPolicies.map((policy) => {
|
|
||||||
const selected = selectedInsurancePolicyIds.includes(policy.id)
|
|
||||||
return (
|
|
||||||
<label key={policy.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-900">{policy.name}</p>
|
|
||||||
<p className="text-xs text-slate-500">{policy.type}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm font-semibold text-slate-800">{fmt(insuranceCharge(policy, days, baseTotal))}</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selected}
|
|
||||||
onChange={(e) => setSelectedInsurancePolicyIds((current) => e.target.checked ? [...current, policy.id] : current.filter((id) => id !== policy.id))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-slate-500">{dict.booking.noOptionalInsurance}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
|
||||||
<label className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{dict.booking.additionalDriver}</p>
|
|
||||||
<p className="text-xs text-slate-500">{dict.booking.charge}: {fmt(additionalDriverCharge(bookingOptions.contractSettings, 1, days))}</p>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" checked={additionalDriverEnabled} onChange={(e) => setAdditionalDriverEnabled(e.target.checked)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{additionalDriverEnabled && (
|
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<input value={additionalDriver.firstName} onChange={(e) => setAdditionalDriver((current) => ({ ...current, firstName: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.firstName.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverFirstName)} />
|
|
||||||
{step2Errors.additionalDriverFirstName && <p className="mt-1 text-xs text-red-600">{step2Errors.additionalDriverFirstName}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input value={additionalDriver.lastName} onChange={(e) => setAdditionalDriver((current) => ({ ...current, lastName: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.lastName.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverLastName)} />
|
|
||||||
{step2Errors.additionalDriverLastName && <p className="mt-1 text-xs text-red-600">{step2Errors.additionalDriverLastName}</p>}
|
|
||||||
</div>
|
|
||||||
<input value={additionalDriver.email} onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.email.toLowerCase()}`} className={inputClass()} />
|
|
||||||
<input value={additionalDriver.phone} onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.phone.toLowerCase()}`} className={inputClass()} />
|
|
||||||
<div>
|
|
||||||
<input value={additionalDriver.driverLicense} onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.driverLicense.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverLicense)} />
|
|
||||||
{step2Errors.additionalDriverLicense && <p className="mt-1 text-xs text-red-600">{step2Errors.additionalDriverLicense}</p>}
|
|
||||||
</div>
|
|
||||||
<input value={additionalDriver.nationality} onChange={(e) => setAdditionalDriver((current) => ({ ...current, nationality: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.nationality.toLowerCase()}`} className={inputClass()} />
|
|
||||||
<input type="date" value={additionalDriver.dateOfBirth} onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))} className={inputClass()} />
|
|
||||||
<input type="date" value={additionalDriver.licenseIssuedAt} onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))} className={inputClass()} />
|
|
||||||
<input type="date" value={additionalDriver.licenseExpiry} onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))} className={inputClass()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.promoCode}</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
value={step2.promoCode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStep2((s) => ({ ...s, promoCode: e.target.value }))
|
|
||||||
setPromoApplied(null)
|
|
||||||
setPromoError('')
|
|
||||||
}}
|
|
||||||
placeholder={dict.booking.enterCode}
|
|
||||||
className={`flex-1 ${inputClass(promoError || undefined)}`}
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={applyPromo} disabled={promoLoading || !step2.promoCode.trim()} className="rounded-xl border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-50">
|
|
||||||
{promoLoading ? '...' : dict.booking.apply}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{promoError && <p className="mt-1 text-xs text-red-600">{promoError}</p>}
|
|
||||||
{promoApplied && <p className="mt-1 text-xs font-medium text-emerald-700">{dict.booking.appliedPromo(promoApplied.title, promoApplied.discountValue)}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<button onClick={() => setStep(1)} className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50">{dict.booking.back}</button>
|
|
||||||
<button onClick={() => validateStep2() && setStep(3)} className="flex-1 rounded-full bg-slate-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700">
|
|
||||||
{dict.booking.reviewBookingCta}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{vehicle && (
|
|
||||||
<div className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">
|
|
||||||
{dict.booking.categoryNotice(dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 divide-y divide-slate-100 text-sm">
|
|
||||||
<SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category} — ${vehicle.make} ${vehicle.model}` : vehicleId} />
|
|
||||||
<SummaryRow label={dict.booking.summary.dates} value={`${step1.startDate} -> ${step1.endDate} (${days} ${dayLabel})`} />
|
|
||||||
<SummaryRow label={dict.booking.summary.primaryDriver} value={`${step2.firstName} ${step2.lastName}`} />
|
|
||||||
<SummaryRow label={dict.booking.summary.email} value={step2.email} />
|
|
||||||
<SummaryRow label={dict.booking.summary.driverLicense} value={step2.driverLicense} />
|
|
||||||
<SummaryRow label={dict.booking.summary.licenseExpiry} value={step2.licenseExpiry} />
|
|
||||||
{additionalDriverEnabled && <SummaryRow label={dict.booking.summary.additionalDriver} value={`${additionalDriver.firstName} ${additionalDriver.lastName}`} />}
|
|
||||||
{promoApplied && <SummaryRow label={dict.booking.summary.promoCode} value={`${step2.promoCode} (-${promoApplied.discountValue}%)`} valueClass="font-semibold text-emerald-700" />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm">
|
|
||||||
<div className="flex justify-between text-slate-600">
|
|
||||||
<span>{days} {dayLabel} × {fmt(vehicle?.dailyRate ?? 0)}</span>
|
|
||||||
<span>{fmt(baseTotal)}</span>
|
|
||||||
</div>
|
|
||||||
{discount > 0 && (
|
|
||||||
<div className="flex justify-between text-emerald-700">
|
|
||||||
<span>{dict.booking.summary.discount(promoApplied?.discountValue ?? 0)}</span>
|
|
||||||
<span>-{fmt(discount)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedPolicies.map((policy) => (
|
|
||||||
<div key={policy.id} className="flex justify-between text-slate-600">
|
|
||||||
<span>{policy.name}</span>
|
|
||||||
<span>{fmt(insuranceCharge(policy, days, baseTotal))}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{additionalDriverEnabled && (
|
|
||||||
<div className="flex justify-between text-slate-600">
|
|
||||||
<span>{dict.booking.additionalDriver}</span>
|
|
||||||
<span>{fmt(additionalDriverTotal)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between border-t border-slate-200 pt-2 text-base font-bold text-slate-900">
|
|
||||||
<span>{dict.booking.summary.total}</span>
|
|
||||||
<span>{fmt(grandTotal)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{submitError && <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{submitError}</div>}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button onClick={() => setStep(2)} className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50">{dict.booking.back}</button>
|
|
||||||
<button onClick={handleSubmit} disabled={submitting} className="flex-1 rounded-full bg-slate-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700 disabled:opacity-60">
|
|
||||||
{submitting ? dict.booking.processing : dict.booking.confirmReservation}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 4 && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-800">
|
|
||||||
{dict.booking.reservationCreated}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{requiresManualApproval && (
|
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
||||||
{dict.booking.manualApprovalRequired}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm">
|
|
||||||
<span className="text-slate-600">{dict.booking.totalDue}</span>
|
|
||||||
<span className="text-base font-black text-slate-900">{fmt(grandTotal)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-sm font-medium text-slate-700">{dict.booking.currency}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['MAD', 'USD', 'EUR'] as const).map((currency) => (
|
|
||||||
<button
|
|
||||||
key={currency}
|
|
||||||
onClick={() => setPaymentCurrency(currency)}
|
|
||||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
paymentCurrency === currency ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{currency}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-3 text-sm font-medium text-slate-700">{dict.booking.paymentMethod}</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(['AMANPAY', 'PAYPAL', 'CASH'] as const).map((provider) => {
|
|
||||||
const disabled = requiresManualApproval && provider !== 'CASH'
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={provider}
|
|
||||||
onClick={() => !disabled && setPaymentProvider(provider)}
|
|
||||||
className={`w-full rounded-xl border-2 p-4 text-left transition-all ${
|
|
||||||
paymentProvider === provider ? 'border-slate-900 bg-slate-50' : 'border-slate-200 hover:border-slate-300'
|
|
||||||
} ${disabled ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-100 text-lg">
|
|
||||||
{provider === 'AMANPAY' ? '🏦' : provider === 'PAYPAL' ? '🔵' : '💵'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-slate-900">
|
|
||||||
{provider === 'AMANPAY' ? dict.booking.paymentMethods.amanpay.title : provider === 'PAYPAL' ? dict.booking.paymentMethods.paypal.title : dict.booking.paymentMethods.cash.title}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-slate-500">
|
|
||||||
{provider === 'AMANPAY' && dict.booking.paymentMethods.amanpay.description}
|
|
||||||
{provider === 'PAYPAL' && dict.booking.paymentMethods.paypal.description}
|
|
||||||
{provider === 'CASH' && dict.booking.paymentMethods.cash.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{paymentError && <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{paymentError}</div>}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={paymentProvider === 'CASH' ? handlePayAtPickup : handlePayOnline}
|
|
||||||
disabled={paymentLoading || (requiresManualApproval && paymentProvider !== 'CASH')}
|
|
||||||
className="w-full rounded-full bg-slate-900 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-slate-700 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{paymentLoading
|
|
||||||
? dict.booking.redirectingToPayment
|
|
||||||
: paymentProvider === 'CASH'
|
|
||||||
? dict.booking.confirmPayAtPickup
|
|
||||||
: dict.booking.payWith(paymentProvider === 'AMANPAY' ? dict.booking.paymentMethods.amanpay.title : dict.booking.paymentMethods.paypal.title)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface ReservationDetail {
|
|
||||||
id: string
|
|
||||||
status: string
|
|
||||||
startDate: string
|
|
||||||
endDate: string
|
|
||||||
totalDays: number
|
|
||||||
totalAmount: number
|
|
||||||
vehicleCategory: string | null
|
|
||||||
vehicle: { make: string; model: string; year: number } | null
|
|
||||||
customer: { firstName: string; lastName: string; email: string } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
searchParams?: { id?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export default async function BookingConfirmationPage({ searchParams = {} }: PageProps) {
|
|
||||||
const reservationId = searchParams.id
|
|
||||||
const slug = DEFAULT_COMPANY_SLUG
|
|
||||||
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
|
|
||||||
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${slug}/brand`, null)
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
|
|
||||||
const reservation = reservationId
|
|
||||||
? await siteFetchOrDefault<ReservationDetail | null>(
|
|
||||||
`/site/${slug}/booking/${reservationId}`,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-16">
|
|
||||||
<div className="shell max-w-2xl">
|
|
||||||
<div className="card p-10 text-center">
|
|
||||||
{pageSections.bookingConfirmation.hero ? (
|
|
||||||
<>
|
|
||||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
|
|
||||||
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-700">{dict.confirmation.eyebrow}</p>
|
|
||||||
<h1 className="mt-3 text-4xl font-black text-slate-900">{dict.confirmation.title}</h1>
|
|
||||||
<p className="mt-4 text-slate-600">
|
|
||||||
{dict.confirmation.description}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{pageSections.bookingConfirmation.summary && reservation && (
|
|
||||||
<div className="mt-8 rounded-xl border border-slate-200 text-left divide-y divide-slate-100 overflow-hidden text-sm">
|
|
||||||
<div className="bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
|
|
||||||
{dict.confirmation.bookingSummary}
|
|
||||||
</div>
|
|
||||||
{reservation.vehicleCategory && (
|
|
||||||
<Row label={dict.confirmation.vehicleCategory} value={dict.vehicles.categoryLabels[reservation.vehicleCategory as keyof typeof dict.vehicles.categoryLabels] ?? reservation.vehicleCategory} />
|
|
||||||
)}
|
|
||||||
{reservation.customer && (
|
|
||||||
<Row label={dict.confirmation.customer} value={`${reservation.customer.firstName} ${reservation.customer.lastName}`} />
|
|
||||||
)}
|
|
||||||
{reservation.customer?.email && (
|
|
||||||
<Row label={dict.confirmation.email} value={reservation.customer.email} />
|
|
||||||
)}
|
|
||||||
<Row
|
|
||||||
label={dict.confirmation.dates}
|
|
||||||
value={`${new Date(reservation.startDate).toLocaleDateString()} -> ${new Date(reservation.endDate).toLocaleDateString()} (${reservation.totalDays})`}
|
|
||||||
/>
|
|
||||||
<Row
|
|
||||||
label={dict.confirmation.total}
|
|
||||||
value={`${reservation.totalAmount.toLocaleString('fr-MA', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 })}`}
|
|
||||||
valueClass="font-bold text-slate-900"
|
|
||||||
/>
|
|
||||||
<Row label={dict.confirmation.reference} value={reservation.id} valueClass="font-mono text-xs text-slate-500 break-all" />
|
|
||||||
<Row
|
|
||||||
label={dict.confirmation.status}
|
|
||||||
value={reservation.status}
|
|
||||||
valueClass="rounded-full bg-amber-100 text-amber-800 px-2 py-0.5 text-xs font-semibold uppercase"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pageSections.bookingConfirmation.summary && !reservation && reservationId && (
|
|
||||||
<p className="mt-6 text-sm text-slate-500">
|
|
||||||
{dict.confirmation.reference}: <span className="font-mono">{reservationId}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pageSections.bookingConfirmation.actions ? <div className="mt-8 flex flex-wrap justify-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/vehicles"
|
|
||||||
className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white"
|
|
||||||
>
|
|
||||||
{dict.confirmation.browseMoreVehicles}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700"
|
|
||||||
>
|
|
||||||
{dict.confirmation.backHome}
|
|
||||||
</Link>
|
|
||||||
</div> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueClass = 'text-slate-700 font-medium',
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
valueClass?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between gap-4 px-4 py-3">
|
|
||||||
<span className="flex-shrink-0 text-slate-500">{label}</span>
|
|
||||||
<span className={`text-right ${valueClass}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Suspense } from 'react'
|
|
||||||
import BookClient from './BookClient'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BookPage() {
|
|
||||||
const language = getPublicSiteLanguage()
|
|
||||||
const dict = getPublicSiteDictionary(language)
|
|
||||||
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-10">
|
|
||||||
<div className="shell max-w-3xl">
|
|
||||||
{pageSections.booking.content ? (
|
|
||||||
<Suspense fallback={<div className="card p-8 text-sm text-slate-500">{dict.booking.loadingForm}</div>}>
|
|
||||||
<BookClient language={language} />
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ContactPage() {
|
|
||||||
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
|
|
||||||
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-16">
|
|
||||||
<div className="shell max-w-2xl">
|
|
||||||
{pageSections.contact.content ? <div className="card p-8">
|
|
||||||
<h1 className="text-3xl font-black text-slate-900">{dict.contact.title}</h1>
|
|
||||||
<p className="mt-3 text-slate-600">{dict.contact.description}</p>
|
|
||||||
</div> : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
html.light {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-white text-slate-900 antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell {
|
|
||||||
@apply mx-auto max-w-6xl px-4 sm:px-6 lg:px-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply rounded-2xl border border-slate-200 bg-white shadow-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
body {
|
|
||||||
@apply transition-colors dark:bg-slate-950 dark:text-slate-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply transition-colors dark:border-slate-800 dark:bg-slate-900;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.dark .bg-slate-50 {
|
|
||||||
background-color: rgb(2 6 23);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .bg-slate-100 {
|
|
||||||
background-color: rgb(15 23 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .bg-white {
|
|
||||||
background-color: rgb(15 23 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .bg-white\/90 {
|
|
||||||
background-color: rgb(15 23 42 / 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .border-slate-200,
|
|
||||||
.dark .border-stone-200,
|
|
||||||
.dark .border-slate-300 {
|
|
||||||
border-color: rgb(30 41 59);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-slate-900 {
|
|
||||||
color: rgb(241 245 249);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-slate-700 {
|
|
||||||
color: rgb(203 213 225);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-slate-600,
|
|
||||||
.dark .text-stone-500 {
|
|
||||||
color: rgb(148 163 184);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-slate-500,
|
|
||||||
.dark .text-slate-400,
|
|
||||||
.dark .text-stone-400 {
|
|
||||||
color: rgb(100 116 139);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-sky-700 {
|
|
||||||
color: rgb(125 211 252);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .bg-sky-100 {
|
|
||||||
background-color: rgb(12 74 110);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-sky-800 {
|
|
||||||
color: rgb(224 242 254);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .hover\:bg-slate-100:hover,
|
|
||||||
.dark .hover\:bg-stone-100:hover {
|
|
||||||
background-color: rgb(30 41 59);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .hover\:text-slate-900:hover,
|
|
||||||
.dark .hover\:text-stone-900:hover {
|
|
||||||
color: rgb(248 250 252);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import type { Metadata } from 'next'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import './globals.css'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import PublicLanguageSwitcher from '@/components/PublicLanguageSwitcher'
|
|
||||||
import PublicThemeSwitcher from '@/components/PublicThemeSwitcher'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'RentalDriveGo Public Site',
|
|
||||||
description: 'Branded company booking site for RentalDriveGo tenants.',
|
|
||||||
icons: {
|
|
||||||
icon: '/rentalcardrive.png',
|
|
||||||
shortcut: '/rentalcardrive.png',
|
|
||||||
apple: '/rentalcardrive.png',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
company: { slug: string; name: string }
|
|
||||||
brand: {
|
|
||||||
displayName: string
|
|
||||||
tagline: string | null
|
|
||||||
heroImageUrl: string | 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 | null
|
|
||||||
showVehicles?: boolean | null
|
|
||||||
showOffers?: boolean | null
|
|
||||||
showPricing?: boolean | null
|
|
||||||
showBlog?: boolean | null
|
|
||||||
showContact?: boolean | null
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveText(value: string | null | undefined, fallback: string) {
|
|
||||||
return value && value.trim() ? value : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveVisible(value: boolean | null | undefined) {
|
|
||||||
return value ?? true
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
const slug = DEFAULT_COMPANY_SLUG
|
|
||||||
const language = getPublicSiteLanguage()
|
|
||||||
const dict = getPublicSiteDictionary(language)
|
|
||||||
const brand = await siteFetchOrDefault<BrandResponse | null>(
|
|
||||||
`/site/${slug}/brand`,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
|
|
||||||
const companyName = brand?.brand?.displayName ?? brand?.company.name ?? 'RentalDriveGo'
|
|
||||||
const menuConfig = brand?.brand?.menuConfig ?? null
|
|
||||||
const headerMenuItems = [
|
|
||||||
{ href: '/about', label: resolveText(menuConfig?.aboutLabel, dict.nav.about), visible: resolveVisible(menuConfig?.showAbout) },
|
|
||||||
{ href: '/vehicles', label: resolveText(menuConfig?.vehiclesLabel, dict.nav.vehicles), visible: resolveVisible(menuConfig?.showVehicles) },
|
|
||||||
{ href: '/offers', label: resolveText(menuConfig?.offersLabel, dict.nav.offers), visible: resolveVisible(menuConfig?.showOffers) },
|
|
||||||
{ href: '/pricing', label: resolveText(menuConfig?.pricingLabel, dict.nav.pricing), visible: resolveVisible(menuConfig?.showPricing) },
|
|
||||||
{ href: '/blog', label: resolveText(menuConfig?.blogLabel, dict.nav.blog), visible: resolveVisible(menuConfig?.showBlog) },
|
|
||||||
{ href: '/contact', label: resolveText(menuConfig?.contactLabel, dict.nav.contact), visible: resolveVisible(menuConfig?.showContact) },
|
|
||||||
]
|
|
||||||
const footerLabel = resolveText(menuConfig?.siteNavigationLabel, dict.nav.siteNavigation)
|
|
||||||
const bookCarLabel = resolveText(menuConfig?.bookCarLabel, dict.nav.bookCar)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html lang={dict.lang} dir={dict.dir} className="light" suppressHydrationWarning>
|
|
||||||
<head>
|
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html:
|
|
||||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('public-site-theme'));if(theme!=='light'&&theme!=='medium'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}var rootTheme=theme==='light'?'light':'dark';document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(rootTheme);document.documentElement.style.colorScheme=rootTheme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body suppressHydrationWarning>
|
|
||||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur-sm transition-colors dark:border-slate-800 dark:bg-slate-950/90">
|
|
||||||
<div className="shell flex h-14 items-center justify-between gap-6">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-2 text-lg font-black tracking-tight text-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-sky-600 text-white text-xs font-black select-none">
|
|
||||||
{companyName.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="hidden sm:inline">{companyName}</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<nav className="flex items-center gap-1">
|
|
||||||
{headerMenuItems.filter((item) => item.visible).map((item) => (
|
|
||||||
<NavLink key={item.href} href={item.href}>{item.label}</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<PublicLanguageSwitcher currentLanguage={language} />
|
|
||||||
<PublicThemeSwitcher currentLanguage={language} />
|
|
||||||
<Link
|
|
||||||
href="/vehicles"
|
|
||||||
className="hidden rounded-full bg-slate-900 px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-950 dark:hover:bg-slate-200 sm:inline-flex"
|
|
||||||
>
|
|
||||||
{bookCarLabel}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<footer className="mt-16 border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-slate-950/90">
|
|
||||||
<div className="shell 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 dark:text-stone-500">
|
|
||||||
{footerLabel}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
|
||||||
<nav className="flex flex-wrap items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400">
|
|
||||||
{headerMenuItems.filter((item) => item.visible).map((item) => (
|
|
||||||
<Link key={item.href} href={item.href} className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-100">
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
|
||||||
© {new Date().getFullYear()} {companyName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetch } from '@/lib/api'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface Offer {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string | null
|
|
||||||
discountValue: number
|
|
||||||
validUntil: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function OffersPage() {
|
|
||||||
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
|
|
||||||
const [offers, brand] = await Promise.all([
|
|
||||||
siteFetch<Offer[]>(`/site/${DEFAULT_COMPANY_SLUG}/offers`).catch(() => []),
|
|
||||||
siteFetch<BrandResponse>(`/site/${DEFAULT_COMPANY_SLUG}/brand`).catch(() => null),
|
|
||||||
])
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-10">
|
|
||||||
<div className="shell">
|
|
||||||
{pageSections.offers.header ? <h1 className="text-4xl font-black text-slate-900">{dict.offers.title}</h1> : null}
|
|
||||||
{pageSections.offers.grid ? <div className={`${pageSections.offers.header ? 'mt-8' : ''} grid gap-5 md:grid-cols-2 xl:grid-cols-3`}>
|
|
||||||
{offers.map((offer) => (
|
|
||||||
<div key={offer.id} className="card p-6">
|
|
||||||
<h2 className="text-xl font-bold text-slate-900">{offer.title}</h2>
|
|
||||||
<p className="mt-3 text-3xl font-black text-sky-700">{offer.discountValue}%</p>
|
|
||||||
<p className="mt-3 text-sm text-slate-600">{offer.description ?? dict.offers.noDescription}</p>
|
|
||||||
<Link href={`/book?offerId=${offer.id}`} className="mt-6 inline-block rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.offers.bookOffer}</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{dict.offers.empty}</div>}
|
|
||||||
</div> : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetch } from '@/lib/api'
|
|
||||||
import {
|
|
||||||
formatCurrency,
|
|
||||||
resolvePublicHomepageLayout,
|
|
||||||
type PublicHomepageLayout,
|
|
||||||
type PublicHomepageSectionType,
|
|
||||||
} from '@rentaldrivego/types'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
company: { slug: string; name: string }
|
|
||||||
brand: {
|
|
||||||
displayName: string
|
|
||||||
tagline: string | null
|
|
||||||
heroImageUrl: string | null
|
|
||||||
publicPhone: string | null
|
|
||||||
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 | null
|
|
||||||
showVehicles?: boolean | null
|
|
||||||
showPricing?: boolean | null
|
|
||||||
layout?: PublicHomepageLayout | null
|
|
||||||
} | null
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Vehicle {
|
|
||||||
id: string
|
|
||||||
make: string
|
|
||||||
model: string
|
|
||||||
dailyRate: number
|
|
||||||
photos: string[]
|
|
||||||
category: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Offer {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
discountValue: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrandHomePageConfig = NonNullable<BrandResponse['brand']>['homePageConfig']
|
|
||||||
|
|
||||||
const pricingPlans = [
|
|
||||||
{
|
|
||||||
name: 'Starter',
|
|
||||||
price: 'MAD 499',
|
|
||||||
cadence: '/month',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Growth',
|
|
||||||
price: 'MAD 999',
|
|
||||||
cadence: '/month',
|
|
||||||
highlighted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Pro',
|
|
||||||
price: 'Custom',
|
|
||||||
cadence: '',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function resolveText(value: string | null | undefined, fallback: string) {
|
|
||||||
return value && value.trim() ? value : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveVisible(value: boolean | null | undefined) {
|
|
||||||
return value ?? true
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveHomepageSectionVisibility(
|
|
||||||
type: PublicHomepageSectionType,
|
|
||||||
pageSections: PublicSitePageSections,
|
|
||||||
homeConfig: BrandHomePageConfig | null | undefined,
|
|
||||||
) {
|
|
||||||
if (!pageSections.home[type]) return false
|
|
||||||
if (type === 'hero') return true
|
|
||||||
if (type === 'offers') return resolveVisible(homeConfig?.showOffers)
|
|
||||||
if (type === 'vehicles') return resolveVisible(homeConfig?.showVehicles)
|
|
||||||
return resolveVisible(homeConfig?.showPricing)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PublicHomePage() {
|
|
||||||
const slug = DEFAULT_COMPANY_SLUG
|
|
||||||
const language = getPublicSiteLanguage()
|
|
||||||
const dict = getPublicSiteDictionary(language)
|
|
||||||
const localizedPlans = pricingPlans.map((plan) => ({
|
|
||||||
...plan,
|
|
||||||
description:
|
|
||||||
plan.name === 'Starter'
|
|
||||||
? dict.pricing.plans.starterDescription
|
|
||||||
: plan.name === 'Growth'
|
|
||||||
? dict.pricing.plans.growthDescription
|
|
||||||
: dict.pricing.plans.proDescription,
|
|
||||||
features:
|
|
||||||
plan.name === 'Starter'
|
|
||||||
? dict.pricing.plans.starterFeatures
|
|
||||||
: plan.name === 'Growth'
|
|
||||||
? dict.pricing.plans.growthFeatures
|
|
||||||
: dict.pricing.plans.proFeatures,
|
|
||||||
}))
|
|
||||||
const [brand, vehicles, offers] = await Promise.all([
|
|
||||||
siteFetch<BrandResponse>(`/site/${slug}/brand`).catch(() => null),
|
|
||||||
siteFetch<Vehicle[]>(`/site/${slug}/vehicles`).catch(() => []),
|
|
||||||
siteFetch<Offer[]>(`/site/${slug}/offers`).catch(() => []),
|
|
||||||
])
|
|
||||||
const homeConfig = brand?.brand?.homePageConfig ?? null
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
const heroTitle = resolveText(homeConfig?.heroTitle, brand?.brand?.tagline ?? dict.home.heroTitleFallback)
|
|
||||||
const heroDescription = resolveText(homeConfig?.heroDescription, dict.home.heroDescription)
|
|
||||||
const viewOffersLabel = resolveText(homeConfig?.viewOffersLabel, dict.home.viewOffers)
|
|
||||||
const viewPricingLabel = resolveText(homeConfig?.viewPricingLabel, dict.home.viewPricing)
|
|
||||||
const contactCompanyLabel = resolveText(homeConfig?.contactCompanyLabel, dict.home.contactCompany)
|
|
||||||
const activeOffersTitle = resolveText(homeConfig?.activeOffersTitle, dict.home.activeOffers)
|
|
||||||
const seeAllOffersLabel = resolveText(homeConfig?.seeAllOffersLabel, dict.home.seeAllOffers)
|
|
||||||
const noActiveOffersLabel = resolveText(homeConfig?.noActiveOffersLabel, dict.home.noActiveOffers)
|
|
||||||
const publishedVehiclesTitle = resolveText(homeConfig?.publishedVehiclesTitle, dict.home.publishedVehicles)
|
|
||||||
const viewVehicleLabel = resolveText(homeConfig?.viewVehicleLabel, dict.home.viewVehicle)
|
|
||||||
const pricingEyebrow = resolveText(homeConfig?.pricingEyebrow, dict.pricing.eyebrow)
|
|
||||||
const pricingTitle = resolveText(homeConfig?.pricingTitle, dict.pricing.title)
|
|
||||||
const pricingDescription = resolveText(homeConfig?.pricingDescription, dict.pricing.description)
|
|
||||||
const visibleHomepageTypes = (['hero', 'offers', 'vehicles', 'pricing'] as PublicHomepageSectionType[]).filter((type) =>
|
|
||||||
resolveHomepageSectionVisibility(type, pageSections, homeConfig),
|
|
||||||
)
|
|
||||||
const homepageLayout = resolvePublicHomepageLayout(homeConfig?.layout, visibleHomepageTypes)
|
|
||||||
|
|
||||||
function renderHomepageSection(type: PublicHomepageSectionType) {
|
|
||||||
if (type === 'hero') {
|
|
||||||
return (
|
|
||||||
<section className="grid h-full gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{brand?.brand?.displayName ?? brand?.company.name ?? dict.siteNameFallback}</p>
|
|
||||||
<h1 className="mt-5 text-5xl font-black tracking-tight text-slate-900">{heroTitle}</h1>
|
|
||||||
<p className="mt-6 text-lg leading-8 text-slate-600">{heroDescription}</p>
|
|
||||||
<div className="mt-10 flex flex-wrap gap-4">
|
|
||||||
<Link href="/offers" className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{viewOffersLabel}</Link>
|
|
||||||
<Link href="/pricing" className="rounded-full border border-sky-200 bg-sky-50 px-6 py-3 text-sm font-semibold text-sky-800">{viewPricingLabel}</Link>
|
|
||||||
<Link href="/contact" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{contactCompanyLabel}</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card overflow-hidden bg-slate-100 min-h-80">
|
|
||||||
{brand?.brand?.heroImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={brand.brand.heroImageUrl} alt={brand.brand.displayName} className="h-full w-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center p-8 text-center text-slate-500">{dict.home.heroImageMissing}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'offers') {
|
|
||||||
return (
|
|
||||||
<section className="h-full">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900">{activeOffersTitle}</h2>
|
|
||||||
<Link href="/offers" className="text-sm font-semibold text-sky-700">{seeAllOffersLabel}</Link>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{offers.map((offer) => (
|
|
||||||
<div key={offer.id} className="card p-5">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">{offer.title}</h3>
|
|
||||||
<p className="mt-3 text-3xl font-black text-sky-700">{offer.discountValue}%</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{noActiveOffersLabel}</div>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'vehicles') {
|
|
||||||
return (
|
|
||||||
<section className="h-full">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900">{publishedVehiclesTitle}</h2>
|
|
||||||
<p className="text-sm text-slate-500">{dict.home.available(vehicles.length)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{vehicles.map((vehicle) => (
|
|
||||||
<article key={vehicle.id} className="card overflow-hidden">
|
|
||||||
<div className="aspect-[16/10] bg-slate-100">
|
|
||||||
{vehicle.photos[0] ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
<h3 className="text-xl font-bold text-slate-900">{vehicle.make} {vehicle.model}</h3>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">{vehicle.category}</p>
|
|
||||||
<div className="mt-4 flex items-end justify-between">
|
|
||||||
<p className="text-xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
|
|
||||||
<Link href={`/vehicles/${vehicle.id}`} className="rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white">{viewVehicleLabel}</Link>
|
|
||||||
</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">{pricingEyebrow}</p>
|
|
||||||
<h2 className="mt-2 text-3xl font-black tracking-tight text-slate-900">{pricingTitle}</h2>
|
|
||||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-600">{pricingDescription}</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/pricing" className="rounded-full border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-700">
|
|
||||||
{viewPricingLabel}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
|
||||||
{localizedPlans.map((plan) => (
|
|
||||||
<article
|
|
||||||
key={plan.name}
|
|
||||||
className={`card p-8 ${plan.highlighted ? 'border-sky-300 shadow-lg shadow-sky-100' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-black text-slate-900">{plan.name}</h3>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-600">{plan.description}</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">
|
|
||||||
{dict.pricing.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>
|
|
||||||
|
|
||||||
<ul className="mt-8 space-y-3">
|
|
||||||
{plan.features.map((feature) => (
|
|
||||||
<li key={feature} className="flex gap-3 text-sm text-slate-700">
|
|
||||||
<span className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-sky-100 text-xs font-bold text-sky-700">+</span>
|
|
||||||
<span>{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_30%)]">
|
|
||||||
<div className="shell py-16">
|
|
||||||
<div className="grid gap-6 lg:hidden">
|
|
||||||
{homepageLayout.items.map((item) => (
|
|
||||||
<div key={`mobile-${item.id}`} className="rounded-[1.75rem] border border-slate-200/80 bg-white/92 p-5 shadow-lg shadow-slate-200/60">
|
|
||||||
{renderHomepageSection(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) * 80}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/60"
|
|
||||||
style={{
|
|
||||||
left: `${((item.x - 1) / 12) * 100}%`,
|
|
||||||
top: `${(item.y - 1) * 80}px`,
|
|
||||||
width: `${(item.w / 12) * 100}%`,
|
|
||||||
height: `${item.h * 80}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderHomepageSection(item.type)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
const plans = [
|
|
||||||
{
|
|
||||||
name: 'Starter',
|
|
||||||
price: 'MAD 499',
|
|
||||||
cadence: '/month',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Growth',
|
|
||||||
price: 'MAD 999',
|
|
||||||
cadence: '/month',
|
|
||||||
highlighted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Pro',
|
|
||||||
price: 'Custom',
|
|
||||||
cadence: '',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PricingPage() {
|
|
||||||
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
|
|
||||||
const brand = await siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null)
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
const localizedPlans = plans.map((plan) => ({
|
|
||||||
...plan,
|
|
||||||
description:
|
|
||||||
plan.name === 'Starter'
|
|
||||||
? dict.pricing.plans.starterDescription
|
|
||||||
: plan.name === 'Growth'
|
|
||||||
? dict.pricing.plans.growthDescription
|
|
||||||
: dict.pricing.plans.proDescription,
|
|
||||||
features:
|
|
||||||
plan.name === 'Starter'
|
|
||||||
? dict.pricing.plans.starterFeatures
|
|
||||||
: plan.name === 'Growth'
|
|
||||||
? dict.pricing.plans.growthFeatures
|
|
||||||
: dict.pricing.plans.proFeatures,
|
|
||||||
}))
|
|
||||||
const faqs = [
|
|
||||||
{
|
|
||||||
question: dict.pricing.faq.paymentMethodsQuestion,
|
|
||||||
answer: dict.pricing.faq.paymentMethodsAnswer,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: dict.pricing.faq.connectPaymentsQuestion,
|
|
||||||
answer: dict.pricing.faq.connectPaymentsAnswer,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: dict.pricing.faq.supportedCurrenciesQuestion,
|
|
||||||
answer: dict.pricing.faq.supportedCurrenciesAnswer,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_18%,#f8fafc)] py-16">
|
|
||||||
<div className="shell space-y-12">
|
|
||||||
{pageSections.pricing.hero ? <section className="mx-auto max-w-3xl text-center">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.pricing.eyebrow}</p>
|
|
||||||
<h1 className="mt-4 text-5xl font-black tracking-tight text-slate-900">{dict.pricing.title}</h1>
|
|
||||||
<p className="mt-5 text-lg leading-8 text-slate-600">
|
|
||||||
{dict.pricing.description}
|
|
||||||
</p>
|
|
||||||
</section> : null}
|
|
||||||
|
|
||||||
{pageSections.pricing.plans ? <section className="grid gap-6 lg:grid-cols-3">
|
|
||||||
{localizedPlans.map((plan) => (
|
|
||||||
<article
|
|
||||||
key={plan.name}
|
|
||||||
className={`card p-8 ${plan.highlighted ? 'border-sky-300 shadow-lg shadow-sky-100' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-black text-slate-900">{plan.name}</h2>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-600">{plan.description}</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">
|
|
||||||
{dict.pricing.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>
|
|
||||||
|
|
||||||
<ul className="mt-8 space-y-3">
|
|
||||||
{plan.features.map((feature) => (
|
|
||||||
<li key={feature} className="flex gap-3 text-sm text-slate-700">
|
|
||||||
<span className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-sky-100 text-xs font-bold text-sky-700">+</span>
|
|
||||||
<span>{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section> : null}
|
|
||||||
|
|
||||||
{pageSections.pricing.payments ? <section className="card p-8">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-black text-slate-900">{dict.pricing.acceptedPaymentMethods}</h2>
|
|
||||||
<p className="mt-2 text-sm text-slate-600">{dict.pricing.paymentsDescription}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-700">AmanPay</span>
|
|
||||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-700">PayPal</span>
|
|
||||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-semibold text-slate-700">MAD · USD · EUR</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section> : null}
|
|
||||||
|
|
||||||
{pageSections.pricing.faq ? <section className="grid gap-4">
|
|
||||||
{faqs.map((faq) => (
|
|
||||||
<article key={faq.question} className="card p-6">
|
|
||||||
<h3 className="text-lg font-bold text-slate-900">{faq.question}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-7 text-slate-600">{faq.answer}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section> : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
|
||||||
import { useCallback } from 'react'
|
|
||||||
import { getPublicSiteDictionary, type PublicSiteLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
interface VehicleFiltersProps {
|
|
||||||
language: PublicSiteLanguage
|
|
||||||
categories: string[]
|
|
||||||
transmissions: string[]
|
|
||||||
maxPossiblePrice: number
|
|
||||||
current: {
|
|
||||||
category: string
|
|
||||||
transmission: string
|
|
||||||
maxPrice: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VehicleFilters({
|
|
||||||
language,
|
|
||||||
categories,
|
|
||||||
transmissions,
|
|
||||||
maxPossiblePrice,
|
|
||||||
current,
|
|
||||||
}: VehicleFiltersProps) {
|
|
||||||
const dict = getPublicSiteDictionary(language)
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
const update = useCallback(
|
|
||||||
(key: string, value: string) => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
const next = { ...current, [key]: value }
|
|
||||||
if (next.category) params.set('category', next.category)
|
|
||||||
if (next.transmission) params.set('transmission', next.transmission)
|
|
||||||
if (next.maxPrice) params.set('maxPrice', next.maxPrice)
|
|
||||||
const qs = params.toString()
|
|
||||||
router.push(qs ? `${pathname}?${qs}` : pathname)
|
|
||||||
},
|
|
||||||
[current, router, pathname],
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasFilters = current.category || current.transmission || current.maxPrice
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card p-5 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="font-semibold text-slate-900">{dict.vehicles.filters.title}</h2>
|
|
||||||
{hasFilters && (
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(pathname)}
|
|
||||||
className="text-xs font-medium text-sky-700 hover:underline"
|
|
||||||
>
|
|
||||||
{dict.vehicles.filters.clearAll}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
|
|
||||||
{dict.vehicles.filters.category}
|
|
||||||
</legend>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<label key={cat} className="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="category"
|
|
||||||
value={cat}
|
|
||||||
checked={current.category === cat}
|
|
||||||
onChange={() => update('category', current.category === cat ? '' : cat)}
|
|
||||||
className="accent-sky-600"
|
|
||||||
/>
|
|
||||||
<span>{cat}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
|
|
||||||
{dict.vehicles.filters.transmission}
|
|
||||||
</legend>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{transmissions.map((tx) => (
|
|
||||||
<label key={tx} className="flex cursor-pointer items-center gap-2.5 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="transmission"
|
|
||||||
value={tx}
|
|
||||||
checked={current.transmission === tx}
|
|
||||||
onChange={() => update('transmission', current.transmission === tx ? '' : tx)}
|
|
||||||
className="accent-sky-600"
|
|
||||||
/>
|
|
||||||
<span>{tx}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
{maxPossiblePrice > 0 && (
|
|
||||||
<fieldset>
|
|
||||||
<legend className="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
|
|
||||||
{dict.vehicles.filters.maxPricePerDay}
|
|
||||||
</legend>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={maxPossiblePrice}
|
|
||||||
step={50}
|
|
||||||
value={current.maxPrice || maxPossiblePrice}
|
|
||||||
onChange={(e) =>
|
|
||||||
update('maxPrice', e.target.value === String(maxPossiblePrice) ? '' : e.target.value)
|
|
||||||
}
|
|
||||||
className="w-full accent-sky-600"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
|
||||||
{dict.vehicles.filters.upTo}{' '}
|
|
||||||
<span className="font-semibold text-slate-900">
|
|
||||||
{current.maxPrice ? `${Number(current.maxPrice).toLocaleString()} MAD` : `${maxPossiblePrice.toLocaleString()} MAD`}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface VehicleDetail {
|
|
||||||
id: string
|
|
||||||
make: string
|
|
||||||
model: string
|
|
||||||
year: number
|
|
||||||
category: string
|
|
||||||
dailyRate: number
|
|
||||||
seats: number
|
|
||||||
transmission: string
|
|
||||||
fuelType: string
|
|
||||||
features: string[]
|
|
||||||
photos: string[]
|
|
||||||
availability: boolean
|
|
||||||
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
|
||||||
nextAvailableAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function VehiclePage({ params }: { params: { id: string } }) {
|
|
||||||
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
|
|
||||||
const [vehicle, brand] = await Promise.all([
|
|
||||||
siteFetchOrDefault<VehicleDetail | null>(`/site/${DEFAULT_COMPANY_SLUG}/vehicles/${params.id}`, null),
|
|
||||||
siteFetchOrDefault<BrandResponse | null>(`/site/${DEFAULT_COMPANY_SLUG}/brand`, null),
|
|
||||||
])
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
|
|
||||||
if (!vehicle) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-10">
|
|
||||||
<div className="shell">
|
|
||||||
<section className="card p-8">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-sky-700">{dict.vehicles.unavailable.eyebrow}</p>
|
|
||||||
<h1 className="mt-3 text-3xl font-black text-slate-900">{dict.vehicles.unavailable.title}</h1>
|
|
||||||
<p className="mt-3 text-slate-600">{dict.vehicles.unavailable.body}</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Link href="/" className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{dict.vehicles.unavailable.backHome}</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const availabilityDate = vehicle.nextAvailableAt
|
|
||||||
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-10">
|
|
||||||
<div className={`shell grid gap-8 ${pageSections.vehicleDetail.gallery && pageSections.vehicleDetail.summary ? 'lg:grid-cols-[1.2fr_0.8fr]' : ''}`}>
|
|
||||||
{pageSections.vehicleDetail.gallery ? <section className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{vehicle.photos.map((photo, index) => (
|
|
||||||
<div key={`${photo}-${index}`} className="card overflow-hidden">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 1}`} className="h-full w-full object-cover" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section> : null}
|
|
||||||
{pageSections.vehicleDetail.summary ? <aside className="card p-6 h-fit">
|
|
||||||
<h1 className="text-4xl font-black text-slate-900">{vehicle.make} {vehicle.model}</h1>
|
|
||||||
<p className="mt-3 text-slate-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
|
|
||||||
<p className="mt-6 text-3xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-slate-500"> {dict.vehicles.detail.perDay}</span></p>
|
|
||||||
{!vehicle.availability && (
|
|
||||||
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
||||||
{availabilityDate ? `${dict.vehicles.detail.availableFrom} ${availabilityDate}` : dict.vehicles.detail.unavailableNoDate}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-6 flex flex-wrap gap-2">
|
|
||||||
{vehicle.features.map((feature) => (
|
|
||||||
<span key={feature} className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{feature}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex gap-3">
|
|
||||||
<Link href={`/book?vehicleId=${vehicle.id}`} className="rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white">{dict.vehicles.detail.bookNow}</Link>
|
|
||||||
<Link href="/" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{dict.vehicles.detail.back}</Link>
|
|
||||||
</div>
|
|
||||||
</aside> : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
import { DEFAULT_COMPANY_SLUG, siteFetchOrDefault } from '@/lib/api'
|
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
|
||||||
import VehicleFilters from './VehicleFilters'
|
|
||||||
import { getPublicSiteDictionary } from '@/lib/i18n'
|
|
||||||
import { getPublicSiteLanguage } from '@/lib/i18n.server'
|
|
||||||
import type { PublicSitePageSections } from '@rentaldrivego/types'
|
|
||||||
import { resolvePageSectionsConfig } from '@/lib/pageSections'
|
|
||||||
|
|
||||||
interface Vehicle {
|
|
||||||
id: string
|
|
||||||
make: string
|
|
||||||
model: string
|
|
||||||
year: number
|
|
||||||
category: string
|
|
||||||
dailyRate: number
|
|
||||||
seats: number
|
|
||||||
transmission: string
|
|
||||||
fuelType: string
|
|
||||||
photos: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrandResponse {
|
|
||||||
brand: {
|
|
||||||
menuConfig?: {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
searchParams?: {
|
|
||||||
category?: string
|
|
||||||
maxPrice?: string
|
|
||||||
transmission?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export default async function VehiclesPage({ searchParams = {} }: PageProps) {
|
|
||||||
const slug = DEFAULT_COMPANY_SLUG
|
|
||||||
const language = getPublicSiteLanguage()
|
|
||||||
const dict = getPublicSiteDictionary(language)
|
|
||||||
const [vehicles, brand] = await Promise.all([
|
|
||||||
siteFetchOrDefault<Vehicle[]>(`/site/${slug}/vehicles`, []),
|
|
||||||
siteFetchOrDefault<BrandResponse | null>(`/site/${slug}/brand`, null),
|
|
||||||
])
|
|
||||||
const pageSections = resolvePageSectionsConfig(brand?.brand?.menuConfig)
|
|
||||||
|
|
||||||
const categories = Array.from(new Set(vehicles.map((v) => v.category))).sort()
|
|
||||||
const transmissions = Array.from(new Set(vehicles.map((v) => v.transmission))).sort()
|
|
||||||
const maxPossiblePrice = vehicles.reduce((max, v) => Math.max(max, v.dailyRate), 0)
|
|
||||||
|
|
||||||
const filtered = vehicles.filter((v) => {
|
|
||||||
if (searchParams.category && v.category !== searchParams.category) return false
|
|
||||||
if (searchParams.transmission && v.transmission !== searchParams.transmission) return false
|
|
||||||
if (searchParams.maxPrice && v.dailyRate > Number(searchParams.maxPrice)) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 py-10">
|
|
||||||
<div className="shell">
|
|
||||||
{pageSections.vehicles.header ? <div className="mb-8">
|
|
||||||
<h1 className="text-4xl font-black text-slate-900">{dict.vehicles.title}</h1>
|
|
||||||
<p className="mt-2 text-slate-500">
|
|
||||||
{dict.vehicles.available(filtered.length)}
|
|
||||||
</p>
|
|
||||||
</div> : null}
|
|
||||||
|
|
||||||
<div className={`grid gap-8 ${pageSections.vehicles.filters ? 'lg:grid-cols-[260px_1fr]' : ''}`}>
|
|
||||||
{pageSections.vehicles.filters ? (
|
|
||||||
<aside>
|
|
||||||
<VehicleFilters
|
|
||||||
language={language}
|
|
||||||
categories={categories}
|
|
||||||
transmissions={transmissions}
|
|
||||||
maxPossiblePrice={maxPossiblePrice}
|
|
||||||
current={{
|
|
||||||
category: searchParams.category ?? '',
|
|
||||||
transmission: searchParams.transmission ?? '',
|
|
||||||
maxPrice: searchParams.maxPrice ?? '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{pageSections.vehicles.grid ? <section>
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="card p-10 text-center">
|
|
||||||
<p className="text-lg font-semibold text-slate-700">{dict.vehicles.noVehiclesTitle}</p>
|
|
||||||
<p className="mt-2 text-sm text-slate-500">{dict.vehicles.noVehiclesBody}</p>
|
|
||||||
<Link
|
|
||||||
href="/vehicles"
|
|
||||||
className="mt-6 inline-block rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white"
|
|
||||||
>
|
|
||||||
{dict.vehicles.clearFilters}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{filtered.map((vehicle) => (
|
|
||||||
<article key={vehicle.id} className="card overflow-hidden flex flex-col">
|
|
||||||
<div className="aspect-[16/10] bg-slate-100">
|
|
||||||
{vehicle.photos[0] ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={vehicle.photos[0]}
|
|
||||||
alt={`${vehicle.make} ${vehicle.model}`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
|
||||||
{dict.vehicles.noPhoto}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col p-5">
|
|
||||||
<h2 className="text-xl font-bold text-slate-900">
|
|
||||||
{vehicle.make} {vehicle.model}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">{vehicle.category}</p>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-600">
|
|
||||||
<span className="rounded-full bg-slate-100 px-3 py-1">{vehicle.transmission}</span>
|
|
||||||
<span className="rounded-full bg-slate-100 px-3 py-1">{dict.vehicles.seats(vehicle.seats)}</span>
|
|
||||||
<span className="rounded-full bg-slate-100 px-3 py-1">{vehicle.fuelType}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto pt-4 flex items-end justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xl font-black text-slate-900">
|
|
||||||
{formatCurrency(vehicle.dailyRate, 'MAD')}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">{dict.vehicles.perDay}</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/vehicles/${vehicle.id}`}
|
|
||||||
className="rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-slate-700 transition-colors"
|
|
||||||
>
|
|
||||||
{dict.vehicles.viewDetails}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { PUBLIC_SITE_LANGUAGE_COOKIE, type PublicSiteLanguage } from '@/lib/i18n'
|
|
||||||
import { SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, writeScopedPreference } from '@/lib/preferences'
|
|
||||||
|
|
||||||
const labels: Record<PublicSiteLanguage, string> = {
|
|
||||||
en: 'Language',
|
|
||||||
fr: 'Langue',
|
|
||||||
ar: 'اللغة',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicLanguageSwitcher({
|
|
||||||
currentLanguage,
|
|
||||||
}: {
|
|
||||||
currentLanguage: PublicSiteLanguage
|
|
||||||
}) {
|
|
||||||
const [language, setLanguage] = useState(currentLanguage)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLanguage(currentLanguage)
|
|
||||||
}, [currentLanguage])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.lang = language
|
|
||||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
|
||||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['public-site-language'])
|
|
||||||
}, [language])
|
|
||||||
|
|
||||||
function handleChange(nextLanguage: PublicSiteLanguage) {
|
|
||||||
setLanguage(nextLanguage)
|
|
||||||
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${nextLanguage}; path=/; max-age=31536000; samesite=lax`
|
|
||||||
document.cookie = `${PUBLIC_SITE_LANGUAGE_COOKIE}=${nextLanguage}; path=/; max-age=31536000; samesite=lax`
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm">
|
|
||||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
|
||||||
{labels[language]}
|
|
||||||
</span>
|
|
||||||
{(['en', 'fr', 'ar'] as PublicSiteLanguage[]).map((value) => {
|
|
||||||
const active = value === language
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleChange(value)}
|
|
||||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
|
||||||
active ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import type { PublicSiteLanguage } from '@/lib/i18n'
|
|
||||||
import { SHARED_THEME_KEY, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
|
||||||
|
|
||||||
type Theme = 'light' | 'medium' | 'dark'
|
|
||||||
|
|
||||||
const labels: Record<PublicSiteLanguage, { theme: string; light: string; medium: string; dark: string }> = {
|
|
||||||
en: { theme: 'Theme', light: 'Light', medium: 'Medium', dark: 'Dark' },
|
|
||||||
fr: { theme: 'Mode', light: 'Clair', medium: 'Moyen', dark: 'Sombre' },
|
|
||||||
ar: { theme: 'الوضع', light: 'فاتح', medium: 'متوسط', dark: 'داكن' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PublicThemeSwitcher({
|
|
||||||
currentLanguage,
|
|
||||||
}: {
|
|
||||||
currentLanguage: PublicSiteLanguage
|
|
||||||
}) {
|
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['public-site-theme'])
|
|
||||||
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
|
|
||||||
setTheme(storedTheme)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
setTheme('dark')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.classList.remove('light', 'dark')
|
|
||||||
document.documentElement.classList.add(theme === 'light' ? 'light' : 'dark')
|
|
||||||
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'
|
|
||||||
document.body.dataset.theme = theme
|
|
||||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['public-site-theme'])
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
const copy = labels[currentLanguage]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900">
|
|
||||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
|
||||||
{copy.theme}
|
|
||||||
</span>
|
|
||||||
{(['light', 'medium', 'dark'] as Theme[]).map((value) => {
|
|
||||||
const active = value === theme
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme(value)}
|
|
||||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
|
||||||
active
|
|
||||||
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-950'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value === 'light' ? copy.light : value === 'medium' ? copy.medium : copy.dark}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
const API_BASE =
|
|
||||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
|
|
||||||
?? process.env.NEXT_PUBLIC_API_URL
|
|
||||||
?? 'http://localhost:4000/api/v1'
|
|
||||||
export const DEFAULT_COMPANY_SLUG = process.env.NEXT_PUBLIC_COMPANY_SLUG ?? 'demo'
|
|
||||||
|
|
||||||
export class SiteApiError extends Error {
|
|
||||||
status: number
|
|
||||||
code?: string
|
|
||||||
|
|
||||||
constructor(message: string, status: number, code?: string) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'SiteApiError'
|
|
||||||
this.status = status
|
|
||||||
this.code = code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function siteFetch<T>(path: string): Promise<T> {
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
|
||||||
const json = await res.json().catch(() => null)
|
|
||||||
if (!res.ok) throw new SiteApiError(json?.message ?? 'Request failed', res.status, json?.error)
|
|
||||||
return json.data as T
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function siteFetchOrDefault<T>(path: string, fallback: T): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await siteFetch<T>(path)
|
|
||||||
} catch {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { cookies } from 'next/headers'
|
|
||||||
import { PUBLIC_SITE_LANGUAGE_COOKIE, isPublicSiteLanguage, type PublicSiteLanguage } from './i18n'
|
|
||||||
import { SHARED_LANGUAGE_COOKIE } from './preferences'
|
|
||||||
|
|
||||||
export function getPublicSiteLanguage(): PublicSiteLanguage {
|
|
||||||
const cookieStore = cookies()
|
|
||||||
const cookieValue =
|
|
||||||
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ??
|
|
||||||
cookieStore.get(PUBLIC_SITE_LANGUAGE_COOKIE)?.value
|
|
||||||
return isPublicSiteLanguage(cookieValue) ? cookieValue : 'en'
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
import {
|
|
||||||
clonePublicSitePageSections,
|
|
||||||
type PublicSitePageSections,
|
|
||||||
} from '@rentaldrivego/types'
|
|
||||||
|
|
||||||
type MaybePageSections = {
|
|
||||||
pageSections?: Partial<PublicSitePageSections> | null
|
|
||||||
} | null | undefined
|
|
||||||
|
|
||||||
export function resolvePageSectionsConfig(menuConfig: MaybePageSections): PublicSitePageSections {
|
|
||||||
const defaults = clonePublicSitePageSections()
|
|
||||||
const custom = menuConfig?.pageSections ?? {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
home: { ...defaults.home, ...(custom.home ?? {}) },
|
|
||||||
about: { ...defaults.about, ...(custom.about ?? {}) },
|
|
||||||
offers: { ...defaults.offers, ...(custom.offers ?? {}) },
|
|
||||||
pricing: { ...defaults.pricing, ...(custom.pricing ?? {}) },
|
|
||||||
vehicles: { ...defaults.vehicles, ...(custom.vehicles ?? {}) },
|
|
||||||
vehicleDetail: { ...defaults.vehicleDetail, ...(custom.vehicleDetail ?? {}) },
|
|
||||||
blog: { ...defaults.blog, ...(custom.blog ?? {}) },
|
|
||||||
contact: { ...defaults.contact, ...(custom.contact ?? {}) },
|
|
||||||
booking: { ...defaults.booking, ...(custom.booking ?? {}) },
|
|
||||||
bookingConfirmation: {
|
|
||||||
...defaults.bookingConfirmation,
|
|
||||||
...(custom.bookingConfirmation ?? {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
|
||||||
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
|
|
||||||
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
|
||||||
|
|
||||||
function readEmployeeToken() {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
|
|
||||||
const localToken = window.localStorage.getItem('employee_token')
|
|
||||||
if (localToken) return localToken
|
|
||||||
|
|
||||||
return document.cookie
|
|
||||||
.split(';')
|
|
||||||
.map((chunk) => chunk.trim())
|
|
||||||
.find((chunk) => chunk.startsWith('employee_token='))
|
|
||||||
?.split('=')[1] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEmployeeId(token: string | null) {
|
|
||||||
if (!token) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const encoded = token.split('.')[1] ?? ''
|
|
||||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
|
||||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
|
||||||
const payload = JSON.parse(atob(padded)) as { sub?: string }
|
|
||||||
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getScopedPreferenceKey(baseKey: string) {
|
|
||||||
const employeeId = decodeEmployeeId(readEmployeeToken())
|
|
||||||
return employeeId ? `${baseKey}:${employeeId}` : baseKey
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getScopedPreferenceCookieName(baseKey: string) {
|
|
||||||
const employeeId = decodeEmployeeId(readEmployeeToken())
|
|
||||||
return employeeId ? `${baseKey}--${employeeId}` : baseKey
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCookie(name: string) {
|
|
||||||
if (typeof document === 'undefined') return null
|
|
||||||
|
|
||||||
return document.cookie
|
|
||||||
.split(';')
|
|
||||||
.map((chunk) => chunk.trim())
|
|
||||||
.find((chunk) => chunk.startsWith(`${name}=`))
|
|
||||||
?.slice(name.length + 1) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeCookie(name: string, value: string) {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readCurrentUserScopedPreference(baseKey: string) {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
|
|
||||||
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
|
|
||||||
if (scopedCookie) return scopedCookie
|
|
||||||
|
|
||||||
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
|
|
||||||
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
|
|
||||||
if (scopedCookie) return scopedCookie
|
|
||||||
|
|
||||||
const sharedCookie = readCookie(baseKey)
|
|
||||||
if (sharedCookie) return sharedCookie
|
|
||||||
|
|
||||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
|
||||||
const candidates = [scopedKey, baseKey, ...legacyKeys]
|
|
||||||
|
|
||||||
for (const key of candidates) {
|
|
||||||
const value = window.localStorage.getItem(key)
|
|
||||||
if (value) return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
|
|
||||||
const scopedKey = getScopedPreferenceKey(baseKey)
|
|
||||||
const scopedCookie = getScopedPreferenceCookieName(baseKey)
|
|
||||||
|
|
||||||
writeCookie(baseKey, value)
|
|
||||||
if (scopedCookie !== baseKey) {
|
|
||||||
writeCookie(scopedCookie, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem(scopedKey, value)
|
|
||||||
window.localStorage.setItem(baseKey, value)
|
|
||||||
|
|
||||||
for (const key of legacyKeys) {
|
|
||||||
window.localStorage.setItem(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import type { NextRequest } from 'next/server'
|
|
||||||
|
|
||||||
const PRODUCTION_DOMAIN = process.env.NEXT_PUBLIC_PUBLIC_SITE_DOMAIN ?? 'rentaldrivego.ma'
|
|
||||||
const DEFAULT_SLUG = process.env.NEXT_PUBLIC_COMPANY_SLUG ?? 'demo'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the company slug from the request host.
|
|
||||||
*
|
|
||||||
* Production: {slug}.<public-site-domain> → slug
|
|
||||||
* Development: localhost / 127.0.0.1 → DEFAULT_SLUG (env var fallback)
|
|
||||||
* Staging/preview URLs that don't match the production domain also fall back.
|
|
||||||
*/
|
|
||||||
function resolveSlug(host: string | null): string {
|
|
||||||
if (!host) return DEFAULT_SLUG
|
|
||||||
|
|
||||||
// Strip port number if present
|
|
||||||
const hostname = host.split(':')[0]
|
|
||||||
|
|
||||||
// Production subdomain: slug.<public-site-domain>
|
|
||||||
if (hostname.endsWith(`.${PRODUCTION_DOMAIN}`)) {
|
|
||||||
const subdomain = hostname.slice(0, hostname.length - PRODUCTION_DOMAIN.length - 1)
|
|
||||||
// Ignore www / bare domain
|
|
||||||
if (subdomain && subdomain !== 'www') return subdomain
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_SLUG
|
|
||||||
}
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
const host = request.headers.get('host')
|
|
||||||
const slug = resolveSlug(host)
|
|
||||||
|
|
||||||
const response = NextResponse.next()
|
|
||||||
|
|
||||||
// Forward the slug as a request header so server components can read it
|
|
||||||
// via `headers()` without needing cookies.
|
|
||||||
response.headers.set('x-company-slug', slug)
|
|
||||||
|
|
||||||
// Also set a short-lived cookie so client components / route handlers
|
|
||||||
// can pick it up without another round-trip.
|
|
||||||
response.cookies.set('x-company-slug', slug, {
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60, // 1 minute – intentionally short; re-derived on every request
|
|
||||||
sameSite: 'lax',
|
|
||||||
httpOnly: false, // readable from JS for client-side fetches
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
/*
|
|
||||||
* Match all request paths except:
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimisation)
|
|
||||||
* - favicon.ico
|
|
||||||
* - public folder files (png, svg, jpg, …)
|
|
||||||
*/
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|otf|css|js)$).*)',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { Config } from 'tailwindcss'
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
content: ['./src/**/*.{ts,tsx}'],
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2017",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [{ "name": "next" }],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
Deploy dev
|
||||||
|
|
||||||
|
Option A — Docker (recommended, full stack)
|
||||||
|
|
||||||
|
# First time or after dependency changes:
|
||||||
|
docker compose -f docker-compose.dev.yml build
|
||||||
|
|
||||||
|
# Start everything (api + all frontend apps + postgres + redis):
|
||||||
|
docker compose -f docker-compose.dev.yml --profile full up
|
||||||
|
|
||||||
|
# Or start only what you need (mix and match profiles):
|
||||||
|
docker compose -f docker-compose.dev.yml --profile api up # api only
|
||||||
|
docker compose -f docker-compose.dev.yml --profile api --profile dashboard up
|
||||||
|
|
||||||
|
First run seeds the database automatically; subsequent runs skip seeding.
|
||||||
|
|
||||||
|
Option B — Local (no Docker for apps)
|
||||||
|
|
||||||
|
# Start infra only:
|
||||||
|
docker compose -f docker-compose.dev.yml up postgres redis
|
||||||
|
|
||||||
|
# Then in a separate terminal:
|
||||||
|
npm run dev # starts all apps via turbo
|
||||||
|
|
||||||
|
Port map (after port reassignment)
|
||||||
|
|
||||||
|
┌─────────────┬───────────────────────────────────────────────┐
|
||||||
|
│ App │ URL │
|
||||||
|
├─────────────┼───────────────────────────────────────────────┤
|
||||||
|
│ public-site │ http://localhost:3000 │
|
||||||
|
├─────────────┼───────────────────────────────────────────────┤
|
||||||
|
│ marketplace │ http://localhost:3001/marketplace │
|
||||||
|
├─────────────┼───────────────────────────────────────────────┤
|
||||||
|
│ dashboard │ http://localhost:3002/dashboard │
|
||||||
|
├─────────────┼───────────────────────────────────────────────┤
|
||||||
|
│ admin │ http://localhost:3003/admin │
|
||||||
|
├─────────────┼───────────────────────────────────────────────┤
|
||||||
|
│ api │ http://localhost:4000 │
|
||||||
|
├─────────────┼───────────────────────────────────────────────┤
|
||||||
|
│ pgAdmin │ http://localhost:5050 (needs --profile tools) │
|
||||||
|
└─────────────┴───────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
▎ Note: In Docker dev each app is directly accessible on its own port. The /marketplace, /dashboard, /admin proxy
|
||||||
|
▎ through port 3000 (public-site) only works when running locally (not in separate containers), since localhost inside
|
||||||
|
▎ a container refers to that container only.
|
||||||
@@ -131,23 +131,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["admin", "full"]
|
profiles: ["admin", "full"]
|
||||||
|
|
||||||
public-site:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.dev
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
env_file:
|
|
||||||
- .env.docker.dev
|
|
||||||
command: ["npm", "run", "dev", "--workspace", "@rentaldrivego/public-site"]
|
|
||||||
ports:
|
|
||||||
- "3003:3003"
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- app_node_modules:/app/node_modules
|
|
||||||
restart: unless-stopped
|
|
||||||
profiles: ["public-site", "full"]
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_dev_data:
|
postgres_dev_data:
|
||||||
redis_dev_data:
|
redis_dev_data:
|
||||||
|
|||||||
@@ -67,19 +67,16 @@ services:
|
|||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
||||||
NEXT_PUBLIC_MARKETING_URL: ${NEXT_PUBLIC_MARKETING_URL}
|
|
||||||
NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL}
|
NEXT_PUBLIC_MARKETPLACE_URL: ${NEXT_PUBLIC_MARKETPLACE_URL}
|
||||||
NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL}
|
NEXT_PUBLIC_DASHBOARD_URL: ${NEXT_PUBLIC_DASHBOARD_URL}
|
||||||
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL}
|
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL}
|
||||||
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN: ${NEXT_PUBLIC_PUBLIC_SITE_DOMAIN}
|
|
||||||
command:
|
command:
|
||||||
- bash
|
- bash
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
npm run start --workspace @rentaldrivego/public-site &
|
|
||||||
npm run start --workspace @rentaldrivego/marketplace &
|
npm run start --workspace @rentaldrivego/marketplace &
|
||||||
npm run start --workspace @rentaldrivego/admin &
|
|
||||||
npm run start --workspace @rentaldrivego/dashboard &
|
npm run start --workspace @rentaldrivego/dashboard &
|
||||||
|
npm run start --workspace @rentaldrivego/admin &
|
||||||
wait -n
|
wait -n
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
@@ -91,19 +88,29 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
# all frontend traffic → port 3000 (public-site proxies /marketplace, /dashboard, /admin internally)
|
# /dashboard and /admin route to their respective ports (higher priority)
|
||||||
- traefik.http.routers.frontend.rule=Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)
|
- traefik.http.routers.frontend-dashboard.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/dashboard`)
|
||||||
- traefik.http.routers.frontend.entrypoints=websecure
|
- traefik.http.routers.frontend-dashboard.entrypoints=websecure
|
||||||
- traefik.http.routers.frontend.tls=true
|
- traefik.http.routers.frontend-dashboard.tls=true
|
||||||
- traefik.http.routers.frontend.tls.certresolver=letsencrypt
|
- traefik.http.routers.frontend-dashboard.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.frontend.service=frontend-svc
|
- traefik.http.routers.frontend-dashboard.service=frontend-dashboard-svc
|
||||||
- traefik.http.services.frontend-svc.loadbalancer.server.port=3000
|
- traefik.http.routers.frontend-dashboard.priority=20
|
||||||
# tenant branded sites — {slug}.rentaldrivego.ma subdomains
|
- traefik.http.services.frontend-dashboard-svc.loadbalancer.server.port=3001
|
||||||
- traefik.http.routers.frontend-tenants.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.${PUBLIC_SITE_DOMAIN}`)
|
- traefik.http.routers.frontend-admin.rule=(Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)) && PathPrefix(`/admin`)
|
||||||
- traefik.http.routers.frontend-tenants.entrypoints=websecure
|
- traefik.http.routers.frontend-admin.entrypoints=websecure
|
||||||
- traefik.http.routers.frontend-tenants.tls=true
|
- traefik.http.routers.frontend-admin.tls=true
|
||||||
- traefik.http.routers.frontend-tenants.tls.certresolver=letsencrypt
|
- traefik.http.routers.frontend-admin.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.frontend-tenants.service=frontend-svc
|
- traefik.http.routers.frontend-admin.service=frontend-admin-svc
|
||||||
|
- traefik.http.routers.frontend-admin.priority=20
|
||||||
|
- traefik.http.services.frontend-admin-svc.loadbalancer.server.port=3002
|
||||||
|
# marketplace catches everything else (root and all other paths)
|
||||||
|
- traefik.http.routers.frontend-marketplace.rule=Host(`${PUBLIC_SITE_DOMAIN}`) || Host(`www.${PUBLIC_SITE_DOMAIN}`)
|
||||||
|
- traefik.http.routers.frontend-marketplace.entrypoints=websecure
|
||||||
|
- traefik.http.routers.frontend-marketplace.tls=true
|
||||||
|
- traefik.http.routers.frontend-marketplace.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.frontend-marketplace.service=frontend-marketplace-svc
|
||||||
|
- traefik.http.routers.frontend-marketplace.priority=10
|
||||||
|
- traefik.http.services.frontend-marketplace-svc.loadbalancer.server.port=3000
|
||||||
|
|
||||||
pgmanage:
|
pgmanage:
|
||||||
image: cmdpromptinc/pgmanage-enterprise:latest
|
image: cmdpromptinc/pgmanage-enterprise:latest
|
||||||
|
|||||||
@@ -2,5 +2,3 @@ export * from './damage'
|
|||||||
export * from './fuel'
|
export * from './fuel'
|
||||||
export * from './api'
|
export * from './api'
|
||||||
export * from './marketplace-homepage'
|
export * from './marketplace-homepage'
|
||||||
export * from './public-homepage-layout'
|
|
||||||
export * from './public-site-sections'
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
export const PUBLIC_HOMEPAGE_GRID_COLUMNS = 12
|
|
||||||
export const PUBLIC_HOMEPAGE_GRID_ROWS = 12
|
|
||||||
|
|
||||||
export type PublicHomepageSectionType = 'hero' | 'offers' | 'vehicles' | 'pricing'
|
|
||||||
|
|
||||||
export type PublicHomepageLayoutItem = {
|
|
||||||
id: string
|
|
||||||
type: PublicHomepageSectionType
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PublicHomepageLayout = {
|
|
||||||
items: PublicHomepageLayoutItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultPublicHomepageLayout: PublicHomepageLayout = {
|
|
||||||
items: [
|
|
||||||
{ id: 'hero', type: 'hero', x: 1, y: 1, w: 7, h: 5 },
|
|
||||||
{ id: 'offers', type: 'offers', x: 8, y: 1, w: 5, h: 3 },
|
|
||||||
{ id: 'vehicles', type: 'vehicles', x: 1, y: 6, w: 7, h: 4 },
|
|
||||||
{ id: 'pricing', type: 'pricing', x: 8, y: 4, w: 5, h: 6 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECTION_LIMITS: Record<PublicHomepageSectionType, { minW: number; minH: number; maxW: number; maxH: number }> = {
|
|
||||||
hero: { minW: 4, minH: 4, maxW: 12, maxH: 8 },
|
|
||||||
offers: { minW: 3, minH: 2, maxW: 12, maxH: 8 },
|
|
||||||
vehicles: { minW: 4, minH: 3, maxW: 12, maxH: 8 },
|
|
||||||
pricing: { minW: 4, minH: 4, maxW: 12, maxH: 9 },
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(Math.max(value, min), max)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clonePublicHomepageLayout(): PublicHomepageLayout {
|
|
||||||
return JSON.parse(JSON.stringify(defaultPublicHomepageLayout)) as PublicHomepageLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPublicHomepageSectionLimits(type: PublicHomepageSectionType) {
|
|
||||||
return SECTION_LIMITS[type]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePublicHomepageLayoutItem(item: PublicHomepageLayoutItem): PublicHomepageLayoutItem {
|
|
||||||
const limits = SECTION_LIMITS[item.type]
|
|
||||||
const w = clamp(Math.round(item.w), limits.minW, limits.maxW)
|
|
||||||
const h = clamp(Math.round(item.h), limits.minH, limits.maxH)
|
|
||||||
const x = clamp(Math.round(item.x), 1, PUBLIC_HOMEPAGE_GRID_COLUMNS - w + 1)
|
|
||||||
const y = clamp(Math.round(item.y), 1, PUBLIC_HOMEPAGE_GRID_ROWS - h + 1)
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolvePublicHomepageLayout(
|
|
||||||
layout?: Partial<PublicHomepageLayout> | null,
|
|
||||||
visibleTypes?: PublicHomepageSectionType[],
|
|
||||||
): PublicHomepageLayout {
|
|
||||||
const defaultsByType = new Map(defaultPublicHomepageLayout.items.map((item) => [item.type, item] as const))
|
|
||||||
const allowedTypes = new Set<PublicHomepageSectionType>(visibleTypes ?? (['hero', 'offers', 'vehicles', 'pricing'] as PublicHomepageSectionType[]))
|
|
||||||
const items = (layout?.items ?? [])
|
|
||||||
.filter((item): item is PublicHomepageLayoutItem => {
|
|
||||||
if (!item || typeof item !== 'object') return false
|
|
||||||
if (!('type' in item) || !allowedTypes.has(item.type as PublicHomepageSectionType)) return false
|
|
||||||
return ['id', 'x', 'y', 'w', 'h'].every((key) => key in item)
|
|
||||||
})
|
|
||||||
.map((item) => normalizePublicHomepageLayoutItem(item))
|
|
||||||
|
|
||||||
const seen = new Set(items.map((item) => item.type))
|
|
||||||
for (const type of allowedTypes) {
|
|
||||||
if (seen.has(type)) continue
|
|
||||||
const fallback = defaultsByType.get(type)
|
|
||||||
if (fallback) items.push(fallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: items.sort((a, b) => (a.y - b.y) || (a.x - b.x)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
export type PublicSitePageSections = {
|
|
||||||
home: {
|
|
||||||
hero: boolean
|
|
||||||
offers: boolean
|
|
||||||
vehicles: boolean
|
|
||||||
pricing: boolean
|
|
||||||
}
|
|
||||||
about: {
|
|
||||||
hero: boolean
|
|
||||||
highlights: boolean
|
|
||||||
details: boolean
|
|
||||||
}
|
|
||||||
offers: {
|
|
||||||
header: boolean
|
|
||||||
grid: boolean
|
|
||||||
}
|
|
||||||
pricing: {
|
|
||||||
hero: boolean
|
|
||||||
plans: boolean
|
|
||||||
payments: boolean
|
|
||||||
faq: boolean
|
|
||||||
}
|
|
||||||
vehicles: {
|
|
||||||
header: boolean
|
|
||||||
filters: boolean
|
|
||||||
grid: boolean
|
|
||||||
}
|
|
||||||
vehicleDetail: {
|
|
||||||
gallery: boolean
|
|
||||||
summary: boolean
|
|
||||||
}
|
|
||||||
blog: {
|
|
||||||
hero: boolean
|
|
||||||
posts: boolean
|
|
||||||
}
|
|
||||||
contact: {
|
|
||||||
content: boolean
|
|
||||||
}
|
|
||||||
booking: {
|
|
||||||
content: boolean
|
|
||||||
}
|
|
||||||
bookingConfirmation: {
|
|
||||||
hero: boolean
|
|
||||||
summary: boolean
|
|
||||||
actions: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultPublicSitePageSections: PublicSitePageSections = {
|
|
||||||
home: {
|
|
||||||
hero: true,
|
|
||||||
offers: true,
|
|
||||||
vehicles: true,
|
|
||||||
pricing: true,
|
|
||||||
},
|
|
||||||
about: {
|
|
||||||
hero: true,
|
|
||||||
highlights: true,
|
|
||||||
details: true,
|
|
||||||
},
|
|
||||||
offers: {
|
|
||||||
header: true,
|
|
||||||
grid: true,
|
|
||||||
},
|
|
||||||
pricing: {
|
|
||||||
hero: true,
|
|
||||||
plans: true,
|
|
||||||
payments: true,
|
|
||||||
faq: true,
|
|
||||||
},
|
|
||||||
vehicles: {
|
|
||||||
header: true,
|
|
||||||
filters: true,
|
|
||||||
grid: true,
|
|
||||||
},
|
|
||||||
vehicleDetail: {
|
|
||||||
gallery: true,
|
|
||||||
summary: true,
|
|
||||||
},
|
|
||||||
blog: {
|
|
||||||
hero: true,
|
|
||||||
posts: true,
|
|
||||||
},
|
|
||||||
contact: {
|
|
||||||
content: true,
|
|
||||||
},
|
|
||||||
booking: {
|
|
||||||
content: true,
|
|
||||||
},
|
|
||||||
bookingConfirmation: {
|
|
||||||
hero: true,
|
|
||||||
summary: true,
|
|
||||||
actions: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clonePublicSitePageSections(): PublicSitePageSections {
|
|
||||||
return JSON.parse(JSON.stringify(defaultPublicSitePageSections)) as PublicSitePageSections
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user