add homepage management

This commit is contained in:
root
2026-05-07 00:34:29 -04:00
parent 750ae56a29
commit c4a45c8b21
33 changed files with 4228 additions and 418 deletions
+12 -6
View File
@@ -1,6 +1,8 @@
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 }
@@ -14,6 +16,9 @@ interface BrandResponse {
publicCountry: string | null
publicAddress: string | null
whatsappNumber: string | null
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
@@ -23,11 +28,12 @@ export default async function AboutPage() {
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">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
{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>
@@ -52,9 +58,9 @@ export default async function AboutPage() {
</div>
)}
</div>
</section>
</section> : null}
<section className="grid gap-6 md:grid-cols-3">
{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>
@@ -67,14 +73,14 @@ export default async function AboutPage() {
<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>
</section> : null}
<section className="card p-8">
{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>
</section> : null}
</div>
</main>
)
+18 -5
View File
@@ -1,21 +1,34 @@
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 default function BlogPage() {
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">
<section className="max-w-3xl">
{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>
</section> : null}
<section className="grid gap-6 lg:grid-cols-3">
{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>
@@ -24,7 +37,7 @@ export default function BlogPage() {
<p className="mt-6 text-sm font-semibold text-slate-900">{dict.blog.draftPlaceholder}</p>
</article>
))}
</section>
</section> : null}
</div>
</main>
)
@@ -2,6 +2,8 @@ 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
@@ -18,12 +20,22 @@ 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>(
@@ -36,19 +48,23 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
<main className="min-h-screen bg-slate-50 py-16">
<div className="shell max-w-2xl">
<div className="card p-10 text-center">
<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>
{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>
<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}
{reservation && (
{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}
@@ -80,13 +96,13 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
</div>
)}
{!reservation && reservationId && (
{pageSections.bookingConfirmation.summary && !reservation && reservationId && (
<p className="mt-6 text-sm text-slate-500">
{dict.confirmation.reference}: <span className="font-mono">{reservationId}</span>
</p>
)}
<div className="mt-8 flex flex-wrap justify-center gap-3">
{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"
@@ -99,7 +115,7 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
>
{dict.confirmation.backHome}
</Link>
</div>
</div> : null}
</div>
</div>
</main>
+19 -4
View File
@@ -2,19 +2,34 @@ 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'
export default function BookPage() {
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">
<Suspense fallback={<div className="card p-8 text-sm text-slate-500">{dict.booking.loadingForm}</div>}>
<BookClient language={language} />
</Suspense>
{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>
)
+16 -3
View File
@@ -1,16 +1,29 @@
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 default function ContactPage() {
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">
<div className="card p-8">
{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>
</div> : null}
</div>
</main>
)
+52 -14
View File
@@ -5,6 +5,7 @@ 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 type { PublicSitePageSections } from '@rentaldrivego/types'
export const metadata: Metadata = {
title: 'RentalDriveGo Public Site',
@@ -13,7 +14,36 @@ export const metadata: Metadata = {
interface BrandResponse {
company: { slug: string; name: string }
brand: { displayName: string; tagline: string | null; heroImageUrl: string | null } | null
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 }) {
@@ -26,6 +56,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
)
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}>
@@ -43,11 +84,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</Link>
<nav className="flex items-center gap-1">
<NavLink href="/about">{dict.nav.about}</NavLink>
<NavLink href="/vehicles">{dict.nav.vehicles}</NavLink>
<NavLink href="/offers">{dict.nav.offers}</NavLink>
<NavLink href="/pricing">{dict.nav.pricing}</NavLink>
<NavLink href="/contact">{dict.nav.contact}</NavLink>
{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">
@@ -56,7 +95,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
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 sm:inline-flex"
>
{dict.nav.bookCar}
{bookCarLabel}
</Link>
</div>
</div>
@@ -67,16 +106,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<footer className="mt-16 border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
<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">
{dict.nav.siteNavigation}
{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">
<Link href="/about" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.about}</Link>
<Link href="/vehicles" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.vehicles}</Link>
<Link href="/offers" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.offers}</Link>
<Link href="/pricing" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.pricing}</Link>
<Link href="/blog" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.blog}</Link>
<Link href="/contact" className="rounded-full px-3 py-1.5 transition hover:bg-stone-100 hover:text-stone-900">{dict.nav.contact}</Link>
{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">
{item.label}
</Link>
))}
</nav>
<p className="text-sm text-stone-500">
&copy; {new Date().getFullYear()} {companyName}
+18 -4
View File
@@ -2,6 +2,8 @@ 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
@@ -11,14 +13,26 @@ interface Offer {
validUntil: string
}
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
export default async function OffersPage() {
const dict = getPublicSiteDictionary(getPublicSiteLanguage())
const offers = await siteFetch<Offer[]>(`/site/${DEFAULT_COMPANY_SLUG}/offers`).catch(() => [])
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">
<h1 className="text-4xl font-black text-slate-900">{dict.offers.title}</h1>
<div className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{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>
@@ -28,7 +42,7 @@ export default async function OffersPage() {
</div>
))}
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{dict.offers.empty}</div>}
</div>
</div> : null}
</div>
</main>
)
+219 -17
View File
@@ -1,12 +1,46 @@
import Link from 'next/link'
import { DEFAULT_COMPANY_SLUG, siteFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
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 } | null
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 {
@@ -24,27 +58,103 @@ interface Offer {
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)
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_30%)]">
<div className="shell py-16 space-y-12">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr] items-center">
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">{brand?.brand?.tagline ?? dict.home.heroTitleFallback}</h1>
<p className="mt-6 text-lg leading-8 text-slate-600">{dict.home.heroDescription}</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">{dict.home.viewOffers}</Link>
<Link href="/contact" className="rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700">{dict.home.contactCompany}</Link>
<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">
@@ -56,11 +166,15 @@ export default async function PublicHomePage() {
)}
</div>
</section>
)
}
<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">{dict.home.activeOffers}</h2>
<Link href="/offers" className="text-sm font-semibold text-sky-700">{dict.home.seeAllOffers}</Link>
<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) => (
@@ -69,13 +183,17 @@ export default async function PublicHomePage() {
<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">{dict.home.noActiveOffers}</div>}
{offers.length === 0 && <div className="card p-6 text-sm text-slate-500">{noActiveOffersLabel}</div>}
</div>
</section>
)
}
<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">{dict.home.publishedVehicles}</h2>
<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">
@@ -92,13 +210,97 @@ export default async function PublicHomePage() {
<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">{dict.home.viewVehicle}</Link>
<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>
)
+22 -9
View File
@@ -1,5 +1,8 @@
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 = [
{
@@ -20,8 +23,18 @@ const plans = [
},
]
export default function PricingPage() {
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:
@@ -55,15 +68,15 @@ export default function PricingPage() {
return (
<main className="min-h-screen bg-[linear-gradient(180deg,#eff6ff,white_18%,#f8fafc)] py-16">
<div className="shell space-y-12">
<section className="mx-auto max-w-3xl text-center">
{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>
</section> : null}
<section className="grid gap-6 lg:grid-cols-3">
{pageSections.pricing.plans ? <section className="grid gap-6 lg:grid-cols-3">
{localizedPlans.map((plan) => (
<article
key={plan.name}
@@ -96,9 +109,9 @@ export default function PricingPage() {
</ul>
</article>
))}
</section>
</section> : null}
<section className="card p-8">
{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>
@@ -110,16 +123,16 @@ export default function PricingPage() {
<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>
</section> : null}
<section className="grid gap-4">
{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>
</section> : null}
</div>
</main>
)
@@ -3,6 +3,8 @@ 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
@@ -18,9 +20,21 @@ interface VehicleDetail {
photos: string[]
}
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 = await siteFetchOrDefault<VehicleDetail | null>(`/site/${DEFAULT_COMPANY_SLUG}/vehicles/${params.id}`, null)
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 (
@@ -41,16 +55,16 @@ export default async function VehiclePage({ params }: { params: { id: string } }
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<section className="grid gap-4 sm:grid-cols-2">
<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>
<aside className="card p-6 h-fit">
</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>
@@ -63,7 +77,7 @@ export default async function VehiclePage({ params }: { params: { id: string } }
<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>
</aside> : null}
</div>
</main>
)
+35 -21
View File
@@ -4,6 +4,8 @@ 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
@@ -18,6 +20,14 @@ interface Vehicle {
photos: string[]
}
interface BrandResponse {
brand: {
menuConfig?: {
pageSections?: Partial<PublicSitePageSections> | null
} | null
} | null
}
interface PageProps {
searchParams?: {
category?: string
@@ -32,7 +42,11 @@ export default async function VehiclesPage({ searchParams = {} }: PageProps) {
const slug = DEFAULT_COMPANY_SLUG
const language = getPublicSiteLanguage()
const dict = getPublicSiteDictionary(language)
const vehicles = await siteFetchOrDefault<Vehicle[]>(`/site/${slug}/vehicles`, [])
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()
@@ -48,31 +62,31 @@ export default async function VehiclesPage({ searchParams = {} }: PageProps) {
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className="shell">
<div className="mb-8">
{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>
</div> : null}
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
{/* Filter sidebar (client component) */}
<aside>
<VehicleFilters
language={language}
categories={categories}
transmissions={transmissions}
maxPossiblePrice={maxPossiblePrice}
current={{
category: searchParams.category ?? '',
transmission: searchParams.transmission ?? '',
maxPrice: searchParams.maxPrice ?? '',
}}
/>
</aside>
<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}
{/* Vehicle grid */}
<section>
{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>
@@ -133,7 +147,7 @@ export default async function VehiclesPage({ searchParams = {} }: PageProps) {
))}
</div>
)}
</section>
</section> : null}
</div>
</div>
</main>
+4
View File
@@ -25,6 +25,7 @@ export type PublicSiteDictionary = {
heroTitleFallback: string
heroDescription: string
viewOffers: string
viewPricing: string
contactCompany: string
heroImageMissing: string
activeOffers: string
@@ -261,6 +262,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
heroTitleFallback: 'Book directly with the rental company.',
heroDescription: 'This branded site handles booking and payment directly. Marketplace discovery routes renters here for checkout.',
viewOffers: 'View offers',
viewPricing: 'View pricing',
contactCompany: 'Contact company',
heroImageMissing: 'Hero image not configured yet.',
activeOffers: 'Active offers',
@@ -507,6 +509,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
heroTitleFallback: "Réservez directement auprès de l'entreprise de location.",
heroDescription: 'Ce site de marque gère directement la réservation et le paiement. La découverte sur la marketplace redirige les locataires ici pour finaliser la commande.',
viewOffers: 'Voir les offres',
viewPricing: 'Voir les tarifs',
contactCompany: "Contacter l'entreprise",
heroImageMissing: "L'image principale n'est pas encore configurée.",
activeOffers: 'Offres actives',
@@ -753,6 +756,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
heroTitleFallback: 'احجز مباشرة مع شركة التأجير.',
heroDescription: 'هذا الموقع المخصص للعلامة التجارية يدير الحجز والدفع مباشرة. الاكتشاف عبر المنصة يحول المستأجرين إلى هنا لإتمام الحجز.',
viewOffers: 'عرض العروض',
viewPricing: 'عرض الأسعار',
contactCompany: 'التواصل مع الشركة',
heroImageMissing: 'لم يتم إعداد صورة رئيسية بعد.',
activeOffers: 'العروض النشطة',
+29
View File
@@ -0,0 +1,29 @@
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 ?? {}),
},
}
}