From 6118b9fd9195c75a1b49eef5211f4a795cdc63f3 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Sun, 24 May 2026 23:20:31 +0000 Subject: [PATCH] Implement booking availability and pricing core --- eslint.config.mjs | 3 +- src/app/api/booking/search/route.ts | 23 + src/app/layout.tsx | 666 +++++++++++++++++++++++++++- src/app/page.tsx | 51 ++- src/lib/booking.ts | 344 ++++++++++++++ src/lib/site.ts | 5 +- 6 files changed, 1086 insertions(+), 6 deletions(-) create mode 100644 src/app/api/booking/search/route.ts create mode 100644 src/lib/booking.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index ab69a31..3cfa335 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,9 +12,8 @@ const compat = new FlatCompat({ const config = [ ...compat.extends('next/core-web-vitals'), { - ignores: ['.next/**', 'node_modules/**'], + ignores: ['.next/**', 'node_modules/**', '.trash/**', '.openclaw/**'], }, ]; export default config; - diff --git a/src/app/api/booking/search/route.ts b/src/app/api/booking/search/route.ts new file mode 100644 index 0000000..01e7099 --- /dev/null +++ b/src/app/api/booking/search/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { searchBookings } from '@/lib/booking'; + +function parseNumber(value: string | null, fallback: number) { + if (value === null || value === '') return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function GET(request: Request) { + const url = new URL(request.url); + const result = searchBookings({ + arrivalDate: url.searchParams.get('arrivalDate') ?? undefined, + departureDate: url.searchParams.get('departureDate') ?? undefined, + adults: parseNumber(url.searchParams.get('adults'), 2), + children: parseNumber(url.searchParams.get('children'), 0), + pets: parseNumber(url.searchParams.get('pets'), 0), + location: url.searchParams.get('location') ?? undefined, + propertySlug: url.searchParams.get('propertySlug') ?? undefined, + }); + + return NextResponse.json(result); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d08ce50..b980b88 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import './globals.scss'; import { SiteFooter } from '@/components/SiteFooter'; import { SiteHeader } from '@/components/SiteHeader'; import { site } from '@/lib/site'; @@ -9,6 +8,669 @@ export const metadata: Metadata = { description: site.description, }; +const globalStyles = String.raw` +:root { + --shell-bg: #f4efe7; + --panel-bg: rgba(255, 255, 255, 0.72); + --panel-border: rgba(26, 23, 20, 0.08); + --accent: #7a543d; + --accent-2: #2e6661; + --text-muted: #63594f; + --shadow: 0 24px 80px rgba(23, 19, 14, 0.12); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at top left, rgba(122, 84, 61, 0.18), transparent 30%), + radial-gradient(circle at 80% 10%, rgba(46, 102, 97, 0.18), transparent 24%), + var(--shell-bg); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +.btn { + font: inherit; +} + +button { + border: 0; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.7rem 1rem; + border: 1px solid transparent; + border-radius: 999px; + cursor: pointer; + line-height: 1.1; + transition: + transform 160ms ease, + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease; +} + +.btn:hover, +.btn:focus-visible { + transform: translateY(-1px); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.btn-primary:hover, +.btn-primary:focus-visible { + background: #6a4732; + border-color: #6a4732; + color: #fff; +} + +.btn-outline-dark { + background: transparent; + border-color: rgba(26, 23, 20, 0.18); + color: #1a1714; +} + +.btn-outline-dark:hover, +.btn-outline-dark:focus-visible { + background: rgba(255, 255, 255, 0.86); + border-color: rgba(26, 23, 20, 0.28); + color: #1a1714; +} + +.btn-dark { + background: #1a1714; + border-color: #1a1714; + color: #fff; +} + +.btn-dark:hover, +.btn-dark:focus-visible { + background: #2a241f; + border-color: #2a241f; + color: #fff; +} + +.text-body-secondary, +.mb-0 { + color: var(--text-muted); +} + +.mb-0 { + margin-bottom: 0 !important; +} + +main { + position: relative; +} + +.app-shell { + min-height: 100vh; + padding: 1.25rem; +} + +.surface { + max-width: 1180px; + margin: 0 auto; + border: 1px solid var(--panel-border); + border-radius: 1.75rem; + background: var(--panel-bg); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + +.site-header, +.site-footer, +.hero, +.section-shell { + padding: 1.5rem; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--panel-border); +} + +.brand-lockup { + display: flex; + align-items: center; + gap: 1rem; +} + +.brand-mark { + display: inline-grid; + place-items: center; + width: 3rem; + height: 3rem; + border-radius: 999px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #fff; + font-weight: 700; + letter-spacing: 0.08em; +} + +.brand-kicker, +.footer-label, +.section-eyebrow { + margin: 0 0 0.25rem; + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-muted); +} + +.brand-lockup h1 { + margin: 0; + font-size: 1.05rem; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.site-nav a { + padding: 0.55rem 0.85rem; + border: 1px solid rgba(26, 23, 20, 0.12); + border-radius: 999px; + font-size: 0.92rem; + color: var(--text-muted); + transition: + transform 160ms ease, + border-color 160ms ease, + background-color 160ms ease; +} + +.site-nav a:hover, +.site-nav a:focus-visible { + transform: translateY(-1px); + border-color: rgba(122, 84, 61, 0.35); + background: rgba(255, 255, 255, 0.8); + color: #1a1714; +} + +.hero { + display: grid; + grid-template-columns: 1.5fr 1fr; + gap: 1.5rem; + align-items: stretch; +} + +.hero-copy, +.hero-panel, +.info-card, +.phase-card, +.data-card { + border: 1px solid var(--panel-border); + border-radius: 1.5rem; + background: rgba(255, 255, 255, 0.78); +} + +.hero-copy { + padding: 2rem; +} + +.hero-copy h2 { + margin: 0 0 1rem; + font-size: clamp(2.2rem, 4vw, 4.2rem); + line-height: 0.95; + letter-spacing: -0.04em; +} + +.hero-copy p { + max-width: 60ch; + color: var(--text-muted); + font-size: 1.06rem; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.hero-actions .btn { + border-radius: 999px; + padding-inline: 1.1rem; +} + +.hero-points { + display: grid; + gap: 0.55rem; + margin: 1.5rem 0 0; + padding-left: 1.1rem; + color: var(--text-muted); +} + +.hero-panel { + display: grid; + gap: 1rem; + padding: 1.5rem; +} + +.search-panel { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(26, 23, 20, 0.08); +} + +.search-field { + display: grid; + gap: 0.35rem; + color: var(--text-muted); + font-size: 0.92rem; +} + +.search-field input, +.contact-form input, +.contact-form textarea { + width: 100%; + border: 1px solid rgba(26, 23, 20, 0.14); + border-radius: 0.9rem; + padding: 0.8rem 0.95rem; + background: rgba(255, 255, 255, 0.94); + color: #1a1714; +} + +.search-field input:focus, +.contact-form input:focus, +.contact-form textarea:focus { + outline: 2px solid rgba(122, 84, 61, 0.28); + outline-offset: 2px; +} + +.quote-panel { + display: grid; + gap: 0.65rem; + padding: 1rem; + border-radius: 1.25rem; + border: 1px solid rgba(46, 102, 97, 0.18); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 248, 247, 0.92)); +} + +.availability-pill { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.availability-pill.is-available { + background: rgba(46, 102, 97, 0.12); + color: var(--accent-2); +} + +.availability-pill.is-unavailable { + background: rgba(122, 84, 61, 0.12); + color: var(--accent); +} + +.quote-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.quote-heading h3 { + margin: 0; +} + +.quote-heading strong { + font-size: 1.5rem; + white-space: nowrap; +} + +.info-card, +.phase-card, +.data-card, +.content-card, +.property-card, +.testimonial-card { + padding: 1rem; +} + +.metric-grid, +.phase-grid, +.data-grid, +.property-grid, +.content-grid, +.testimonial-grid, +.card-stack, +.content-stack { + display: grid; + gap: 1rem; +} + +.metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metric { + padding: 1rem; + border-radius: 1rem; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 240, 231, 0.9)); +} + +.metric strong { + display: block; + margin-bottom: 0.3rem; + font-size: 1.6rem; +} + +.section-heading h2 { + margin: 0; + font-size: clamp(1.5rem, 2vw, 2.1rem); +} + +.section-description { + max-width: 65ch; + margin: 0.5rem 0 0; + color: var(--text-muted); +} + +.phase-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.phase-card h3, +.data-card h3 { + margin-top: 0; + font-size: 1.05rem; +} + +.phase-card ul, +.data-card ul { + margin: 0.75rem 0 0; + padding-left: 1.1rem; + color: var(--text-muted); +} + +.data-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.property-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.property-card { + display: grid; + gap: 0.9rem; + border: 1px solid var(--panel-border); + border-radius: 1.35rem; + background: rgba(255, 255, 255, 0.82); +} + +.property-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.property-card h3, +.content-card h3, +.testimonial-card strong { + margin: 0; +} + +.property-price { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: rgba(46, 102, 97, 0.12); + color: var(--accent-2); + font-size: 0.85rem; + white-space: nowrap; +} + +.property-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; +} + +.property-metrics div { + padding: 0.75rem; + border-radius: 0.95rem; + background: rgba(244, 239, 231, 0.88); +} + +.property-metrics dt { + color: var(--text-muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.14em; +} + +.property-metrics dd { + margin: 0.25rem 0 0; + font-size: 1rem; + font-weight: 700; +} + +.tag-list, +.link-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.tag-list li { + padding: 0.32rem 0.62rem; + border-radius: 999px; + background: rgba(122, 84, 61, 0.12); + color: var(--accent); + font-size: 0.82rem; +} + +.content-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.content-grid-tight { + align-items: flex-start; +} + +.content-card, +.testimonial-card { + border: 1px solid var(--panel-border); + border-radius: 1.35rem; + background: rgba(255, 255, 255, 0.82); +} + +.content-card p:last-child, +.testimonial-card p:last-child { + margin-bottom: 0; +} + +.inline-link { + color: var(--accent-2); + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.testimonial-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.testimonial-card { + display: grid; + gap: 1rem; +} + +.testimonial-card footer { + display: grid; + gap: 0.15rem; + color: var(--text-muted); +} + +.cta-band, +.page-hero { + border: 1px solid var(--panel-border); + border-radius: 1.5rem; + background: rgba(255, 255, 255, 0.82); +} + +.cta-band { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.1rem 1.25rem; +} + +.page-hero { + margin: 1.5rem; + padding: 1.5rem; +} + +.page-hero h2 { + margin: 0 0 0.6rem; + font-size: clamp(2rem, 4vw, 3.5rem); + line-height: 0.98; + letter-spacing: -0.04em; +} + +.page-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.75fr); + gap: 0.5rem; +} + +.page-layout .section-shell { + padding-top: 0; +} + +.contact-form { + display: grid; + gap: 0.9rem; +} + +.contact-form label { + display: grid; + gap: 0.35rem; + color: var(--text-muted); +} + +.contact-form-message { + grid-column: 1 / -1; +} + +.contact-aside { + display: grid; + gap: 1rem; + align-content: start; + padding: 1.5rem 1.5rem 1.5rem 0; +} + +.content-sidebar { + display: grid; + gap: 1rem; + align-content: start; +} + +.site-footer { + display: flex; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--panel-border); + color: var(--text-muted); +} + +.site-footer p { + margin: 0; +} + +@media (max-width: 900px) { + .hero, + .phase-grid, + .data-grid, + .property-grid, + .content-grid, + .testimonial-grid, + .page-layout { + grid-template-columns: 1fr; + } + + .site-header, + .site-footer, + .cta-band { + flex-direction: column; + align-items: flex-start; + } + + .contact-aside { + padding: 0 1.5rem 1.5rem; + } +} + +@media (max-width: 640px) { + .app-shell { + padding: 0.75rem; + } + + .site-header, + .hero, + .section-shell, + .site-footer { + padding: 1rem; + } + + .page-hero { + margin: 0.75rem; + padding: 1rem; + } + + .metric-grid { + grid-template-columns: 1fr; + } + + .property-metrics { + grid-template-columns: 1fr; + } +} +`; + export default function RootLayout({ children, }: Readonly<{ @@ -24,8 +686,8 @@ export default function RootLayout({ + ); } - diff --git a/src/app/page.tsx b/src/app/page.tsx index 084a67f..72e9066 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import { Section } from '@/components/Section'; +import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking'; import { featuredProperties, locationHighlights, @@ -20,6 +21,14 @@ const bookingFields = [ { label: 'Area', value: 'Coastal or rural' }, ]; +const demoQuote = quoteStay(bookingCatalog[0]!, { + arrivalDate: '2026-07-10', + departureDate: '2026-07-14', + adults: 2, + children: 1, + pets: 0, +}); + export default function HomePage() { return ( <> @@ -65,9 +74,49 @@ export default function HomePage() { Check availability + +
+
+ {demoQuote.available ? 'Available now' : 'Unavailable'} +
+
+
+

Live quote core

+

{demoQuote.propertyName}

+
+ {formatPoundsFromCents(demoQuote.totalCents)} +
+

+ {demoQuote.arrivalDate} to {demoQuote.departureDate} • {demoQuote.nights} nights +

+

+ Booking hold: {demoQuote.holdExpiresAt ? '30 minutes after checkout starts' : 'not available yet'} +

+
+
+
+
+

Availability checks

+

+ Published properties are filtered by search terms, guest count, pet rules, minimum stay, and any blocked or already-booked date ranges. +

+
+
+

Pricing rules

+

+ The quote core applies seasonal pricing, weekend overrides, guest supplements, and a 30-minute hold window for the booking start step. +

+
+
+
+

What comes next

- The next tickets can now focus on the property listing and property detail pages while the public content layer stays reusable. + The next tickets can now focus on the property listing and property detail pages while the public content layer and booking core stay reusable.

Enquire through the contact page diff --git a/src/lib/booking.ts b/src/lib/booking.ts new file mode 100644 index 0000000..df71cb3 --- /dev/null +++ b/src/lib/booking.ts @@ -0,0 +1,344 @@ +export type BookingSearchInput = { + arrivalDate?: string; + departureDate?: string; + adults: number; + children: number; + pets: number; + location?: string; + propertySlug?: string; +}; + +type DateRange = { + startDate: string; + endDate: string; + reason: string; +}; + +type SeasonalRate = { + label: string; + startDate: string; + endDate: string; + nightlyCents: number; + weekendNightlyCents?: number; +}; + +export type BookingPropertyProfile = { + slug: string; + name: string; + area: string; + summary: string; + sleeps: number; + bedrooms: number; + bathrooms: number; + published: boolean; + petsAllowed: boolean; + minStayNights: number; + baseNightlyCents: number; + weekendNightlyCents?: number; + guestSupplementCents?: number; + seasonalRates: SeasonalRate[]; + availabilityBlocks: DateRange[]; + confirmedBookings: DateRange[]; +}; + +export type BookingQuote = { + propertySlug: string; + propertyName: string; + area: string; + available: boolean; + nights: number; + arrivalDate?: string; + departureDate?: string; + holdExpiresAt?: string; + reasons: string[]; + nightlyRates: Array<{ date: string; label: string; amountCents: number }>; + priceBreakdown: Array<{ label: string; amountCents: number }>; + totalCents: number; +}; + +export type BookingSearchResult = BookingQuote & { + sleeps: number; + bedrooms: number; + bathrooms: number; + petsAllowed: boolean; +}; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const BOOKING_HOLD_MINUTES = 30; +const INCLUDED_GUESTS = 2; + +export const bookingCatalog: BookingPropertyProfile[] = [ + { + slug: 'coastal-view-cottage', + name: 'Coastal View Cottage', + area: 'Clifftop village', + summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.', + sleeps: 4, + bedrooms: 2, + bathrooms: 2, + published: true, + petsAllowed: false, + minStayNights: 2, + baseNightlyCents: 18500, + weekendNightlyCents: 21500, + guestSupplementCents: 1800, + seasonalRates: [ + { + label: 'Summer high season', + startDate: '2026-06-01', + endDate: '2026-09-30', + nightlyCents: 22500, + weekendNightlyCents: 25500, + }, + ], + availabilityBlocks: [ + { startDate: '2026-03-15', endDate: '2026-03-18', reason: 'MAINTENANCE' }, + { startDate: '2026-08-18', endDate: '2026-08-25', reason: 'OWNER_BLOCKED' }, + ], + confirmedBookings: [{ startDate: '2026-07-21', endDate: '2026-07-28', reason: 'CONFIRMED_BOOKING' }], + }, + { + slug: 'orchard-barn', + name: 'Orchard Barn', + area: 'Rural retreat', + summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.', + sleeps: 6, + bedrooms: 3, + bathrooms: 2, + published: true, + petsAllowed: true, + minStayNights: 3, + baseNightlyCents: 21000, + weekendNightlyCents: 24000, + guestSupplementCents: 1200, + seasonalRates: [ + { + label: 'Harvest season', + startDate: '2026-09-01', + endDate: '2026-10-31', + nightlyCents: 23000, + weekendNightlyCents: 26000, + }, + ], + availabilityBlocks: [{ startDate: '2026-05-12', endDate: '2026-05-17', reason: 'MAINTENANCE' }], + confirmedBookings: [{ startDate: '2026-06-12', endDate: '2026-06-19', reason: 'CONFIRMED_BOOKING' }], + }, + { + slug: 'harbour-house', + name: 'Harbour House', + area: 'Harbour front', + summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.', + sleeps: 5, + bedrooms: 3, + bathrooms: 1, + published: true, + petsAllowed: false, + minStayNights: 3, + baseNightlyCents: 16500, + weekendNightlyCents: 19000, + guestSupplementCents: 1500, + seasonalRates: [ + { + label: 'Peak summer', + startDate: '2026-07-01', + endDate: '2026-08-31', + nightlyCents: 19500, + weekendNightlyCents: 22500, + }, + ], + availabilityBlocks: [{ startDate: '2026-06-01', endDate: '2026-06-05', reason: 'OWNER_BLOCKED' }], + confirmedBookings: [{ startDate: '2026-08-12', endDate: '2026-08-19', reason: 'CONFIRMED_BOOKING' }], + }, +]; + +function parseDate(value?: string) { + if (!value) return null; + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatDate(date: Date) { + return date.toISOString().slice(0, 10); +} + +function addDays(date: Date, days: number) { + return new Date(date.getTime() + days * MS_PER_DAY); +} + +function diffInNights(arrival: Date, departure: Date) { + return Math.round((departure.getTime() - arrival.getTime()) / MS_PER_DAY); +} + +function rangesOverlap(startA: Date, endA: Date, startB: Date, endB: Date) { + return startA < endB && endA > startB; +} + +function formatCurrency(cents: number) { + return new Intl.NumberFormat('en-GB', { + style: 'currency', + currency: 'GBP', + maximumFractionDigits: 0, + }).format(cents / 100); +} + +function getRateForDate(property: BookingPropertyProfile, night: Date) { + const seasonalRate = property.seasonalRates.find((rate) => { + const start = parseDate(rate.startDate); + const end = parseDate(rate.endDate); + return start && end ? night >= start && night <= end : false; + }); + + const isWeekend = night.getUTCDay() === 5 || night.getUTCDay() === 6; + if (seasonalRate) { + return { + label: seasonalRate.label, + amountCents: isWeekend && seasonalRate.weekendNightlyCents ? seasonalRate.weekendNightlyCents : seasonalRate.nightlyCents, + }; + } + + return { + label: isWeekend && property.weekendNightlyCents ? 'Weekend rate' : 'Base rate', + amountCents: isWeekend && property.weekendNightlyCents ? property.weekendNightlyCents : property.baseNightlyCents, + }; +} + +function collectConflicts(property: BookingPropertyProfile, arrival: Date, departure: Date) { + const conflicts: string[] = []; + + for (const block of [...property.availabilityBlocks, ...property.confirmedBookings]) { + const start = parseDate(block.startDate); + const end = parseDate(block.endDate); + if (!start || !end) continue; + + if (rangesOverlap(arrival, departure, start, end)) { + conflicts.push( + block.reason === 'CONFIRMED_BOOKING' + ? `Booked from ${block.startDate} to ${block.endDate}` + : `Unavailable from ${block.startDate} to ${block.endDate}`, + ); + } + } + + return conflicts; +} + +export function quoteStay(property: BookingPropertyProfile, input: BookingSearchInput): BookingQuote { + const reasons: string[] = []; + const arrival = parseDate(input.arrivalDate); + const departure = parseDate(input.departureDate); + const adults = Number.isFinite(input.adults) ? input.adults : 0; + const children = Number.isFinite(input.children) ? input.children : 0; + const pets = Number.isFinite(input.pets) ? input.pets : 0; + const guestCount = adults + children; + + let nights = 0; + if (!arrival || !departure) { + reasons.push('Select arrival and departure dates to check availability.'); + } else if (departure <= arrival) { + reasons.push('Departure must be after arrival.'); + } else { + nights = diffInNights(arrival, departure); + if (nights < property.minStayNights) { + reasons.push(`Minimum stay for this property is ${property.minStayNights} nights.`); + } + } + + if (guestCount > property.sleeps) { + reasons.push(`This property sleeps up to ${property.sleeps} guests.`); + } + + if (pets > 0 && !property.petsAllowed) { + reasons.push('Pets are not allowed for this property.'); + } + + if (arrival && departure && departure > arrival) { + reasons.push(...collectConflicts(property, arrival, departure)); + } + + const available = reasons.length === 0; + const nightlyRates: BookingQuote['nightlyRates'] = []; + const priceBreakdown: BookingQuote['priceBreakdown'] = []; + + if (available && arrival && departure) { + for (let day = 0; day < nights; day += 1) { + const night = addDays(arrival, day); + const nightlyRate = getRateForDate(property, night); + nightlyRates.push({ + date: formatDate(night), + label: nightlyRate.label, + amountCents: nightlyRate.amountCents, + }); + } + + const accommodationCents = nightlyRates.reduce((sum, item) => sum + item.amountCents, 0); + const guestSupplementCents = Math.max(0, guestCount - INCLUDED_GUESTS) * (property.guestSupplementCents ?? 0) * nights; + + priceBreakdown.push({ label: 'Accommodation', amountCents: accommodationCents }); + if (guestSupplementCents > 0) { + priceBreakdown.push({ label: 'Guest supplement', amountCents: guestSupplementCents }); + } + priceBreakdown.push({ label: `Hold for ${BOOKING_HOLD_MINUTES} minutes`, amountCents: 0 }); + } + + const totalCents = priceBreakdown.reduce((sum, item) => sum + item.amountCents, 0); + + return { + propertySlug: property.slug, + propertyName: property.name, + area: property.area, + available, + nights, + arrivalDate: arrival ? formatDate(arrival) : undefined, + departureDate: departure ? formatDate(departure) : undefined, + holdExpiresAt: available ? new Date(Date.now() + BOOKING_HOLD_MINUTES * 60 * 1000).toISOString() : undefined, + reasons, + nightlyRates, + priceBreakdown, + totalCents, + }; +} + +export function searchBookings(input: BookingSearchInput) { + const locationQuery = input.location?.trim().toLowerCase() ?? ''; + const results: BookingSearchResult[] = bookingCatalog + .filter((property) => { + if (!property.published) return false; + if (input.propertySlug && property.slug !== input.propertySlug) return false; + if (!locationQuery) return true; + const searchable = `${property.name} ${property.area} ${property.summary} ${property.slug}`.toLowerCase(); + return searchable.includes(locationQuery); + }) + .map((property) => ({ + ...quoteStay(property, input), + sleeps: property.sleeps, + bedrooms: property.bedrooms, + bathrooms: property.bathrooms, + petsAllowed: property.petsAllowed, + })) + .sort((a, b) => { + if (a.available !== b.available) return a.available ? -1 : 1; + if (a.totalCents !== b.totalCents) return a.totalCents - b.totalCents; + return a.propertyName.localeCompare(b.propertyName); + }); + + return { + search: { + arrivalDate: input.arrivalDate, + departureDate: input.departureDate, + adults: input.adults, + children: input.children, + pets: input.pets, + location: input.location ?? '', + }, + results, + }; +} + +export function formatPoundsFromCents(cents: number) { + return formatCurrency(cents); +} diff --git a/src/lib/site.ts b/src/lib/site.ts index a2b86b7..6a6d438 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -155,7 +155,10 @@ export const contentPages: ContentPage[] = [ sections: [ { title: 'When will live availability arrive?', - paragraphs: ['Availability and pricing will be added in the dedicated booking and pricing slices that follow this public content work.'], + paragraphs: [ + 'Availability and pricing now share a reusable core so the public site can check dates and preview a total before checkout.', + 'Later tickets will wire that core into the property pages and booking start flow.', + ], }, { title: 'Can guests still enquire now?',