Implement booking availability and pricing core

This commit is contained in:
2026-05-24 23:20:31 +00:00
parent 014307f2ec
commit 6118b9fd91
6 changed files with 1086 additions and 6 deletions

View File

@@ -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);
}

View File

@@ -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({
<SiteFooter />
</div>
</div>
<style>{globalStyles}</style>
</body>
</html>
);
}

View File

@@ -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
</button>
</form>
<div className="quote-panel" aria-label="Booking quote preview">
<div className={`availability-pill ${demoQuote.available ? 'is-available' : 'is-unavailable'}`}>
{demoQuote.available ? 'Available now' : 'Unavailable'}
</div>
<div className="quote-heading">
<div>
<p className="footer-label">Live quote core</p>
<h3>{demoQuote.propertyName}</h3>
</div>
<strong>{formatPoundsFromCents(demoQuote.totalCents)}</strong>
</div>
<p className="mb-0">
{demoQuote.arrivalDate} to {demoQuote.departureDate} {demoQuote.nights} nights
</p>
<p className="mb-0 text-body-secondary">
Booking hold: {demoQuote.holdExpiresAt ? '30 minutes after checkout starts' : 'not available yet'}
</p>
</div>
</aside>
</section>
<Section
eyebrow="Booking core"
title="Availability and pricing are now based on explicit rules"
description="The site can check dates, block conflicts, and preview a booking total before payment starts. Later tickets can reuse the same core on the property pages and checkout path."
>
<div className="data-grid">
<article className="data-card">
<h3>Availability checks</h3>
<p>
Published properties are filtered by search terms, guest count, pet rules, minimum stay, and any blocked or already-booked date ranges.
</p>
</article>
<article className="data-card">
<h3>Pricing rules</h3>
<p>
The quote core applies seasonal pricing, weekend overrides, guest supplements, and a 30-minute hold window for the booking start step.
</p>
</article>
</div>
</Section>
<Section
id="browse"
eyebrow="Featured stays"
@@ -206,7 +255,7 @@ export default function HomePage() {
<article className="content-card">
<h3>What comes next</h3>
<p>
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.
</p>
<Link className="inline-link" href="/contact">
Enquire through the contact page

344
src/lib/booking.ts Normal file
View File

@@ -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);
}

View File

@@ -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?',