Compare commits
3 Commits
vik-118-ch
...
feature/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| bb80906d19 | |||
| ab2f9677fa | |||
| 3d334a6b96 |
@@ -1,23 +1,24 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { seedPropertyInventory } from '@/lib/properties';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const existing = await prisma.siteSettings.findFirst();
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
if (!existing) {
|
||||
await prisma.siteSettings.create({
|
||||
data: {
|
||||
businessName: 'Holiday Property Booking',
|
||||
tagline: 'Curated stays, clear availability, and a direct booking flow.',
|
||||
contactEmail: 'hello@example.com',
|
||||
defaultSeoTitle: 'Holiday Property Booking',
|
||||
defaultSeoDescription: 'Book holiday properties with live availability, clear pricing, and secure checkout.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.siteSettings.create({
|
||||
data: {
|
||||
businessName: 'Holiday Property Booking',
|
||||
tagline: 'Curated stays, clear availability, and a direct booking flow.',
|
||||
contactEmail: 'hello@example.com',
|
||||
defaultSeoTitle: 'Holiday Property Booking',
|
||||
defaultSeoDescription: 'Book holiday properties with live availability, clear pricing, and secure checkout.',
|
||||
},
|
||||
});
|
||||
await seedPropertyInventory();
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -28,4 +29,3 @@ main()
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ export const metadata: Metadata = {
|
||||
description: 'Start a holiday property booking, check the live quote core, and continue to checkout.',
|
||||
};
|
||||
|
||||
type NewBookingPageProps = {
|
||||
searchParams?: Promise<{
|
||||
propertySlug?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function startBooking(formData: FormData) {
|
||||
'use server';
|
||||
|
||||
@@ -32,7 +38,12 @@ async function startBooking(formData: FormData) {
|
||||
redirect(result.checkoutUrl);
|
||||
}
|
||||
|
||||
export default function NewBookingPage() {
|
||||
export default async function NewBookingPage({ searchParams }: NewBookingPageProps) {
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const selectedPropertySlug = resolvedSearchParams?.propertySlug;
|
||||
const selectedProperty =
|
||||
bookingCatalog.find((property) => property.slug === selectedPropertySlug) ?? bookingCatalog[0] ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-hero">
|
||||
@@ -48,12 +59,16 @@ export default function NewBookingPage() {
|
||||
<Section
|
||||
eyebrow="Booking form"
|
||||
title="Create a booking hold"
|
||||
description="Enter the stay details and guest information needed before checkout."
|
||||
description={
|
||||
selectedProperty
|
||||
? `Enter the stay details for ${selectedProperty.name} and continue into the checkout flow.`
|
||||
: 'Enter the stay details and guest information needed before checkout.'
|
||||
}
|
||||
>
|
||||
<form className="contact-form" action={startBooking}>
|
||||
<label>
|
||||
<span>Property</span>
|
||||
<select name="propertySlug" defaultValue={bookingCatalog[0]?.slug}>
|
||||
<select name="propertySlug" defaultValue={selectedProperty?.slug}>
|
||||
{bookingCatalog.map((property) => (
|
||||
<option key={property.slug} value={property.slug}>
|
||||
{property.name}
|
||||
@@ -120,6 +135,16 @@ export default function NewBookingPage() {
|
||||
</Section>
|
||||
|
||||
<aside className="content-sidebar">
|
||||
{selectedProperty ? (
|
||||
<article className="content-card">
|
||||
<p className="footer-label">Selected stay</p>
|
||||
<h3>{selectedProperty.name}</h3>
|
||||
<p>{selectedProperty.summary}</p>
|
||||
<p className="mb-0">
|
||||
{selectedProperty.sleeps} guests · {selectedProperty.bedrooms} bedrooms · {selectedProperty.bathrooms} bathrooms
|
||||
</p>
|
||||
</article>
|
||||
) : null}
|
||||
<article className="content-card">
|
||||
<p className="footer-label">Flow</p>
|
||||
<ul className="admin-bullet-list">
|
||||
|
||||
@@ -8,13 +8,25 @@ export const metadata: Metadata = {
|
||||
description: 'Get in touch about a holiday stay, a booking question, or a property enquiry.',
|
||||
};
|
||||
|
||||
type ContactPageProps = {
|
||||
searchParams?: Promise<{
|
||||
property?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const reasons = [
|
||||
'Ask about a property before the booking engine is live',
|
||||
'Request more details about a location or house rules',
|
||||
'Raise a special requirement or accessibility question',
|
||||
];
|
||||
|
||||
export default function ContactPage() {
|
||||
export default async function ContactPage({ searchParams }: ContactPageProps) {
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const property = resolvedSearchParams?.property?.trim();
|
||||
const messagePlaceholder = property
|
||||
? `Tell us a little about your stay or question for ${property}.`
|
||||
: 'Tell us a little about your stay or question.';
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-hero">
|
||||
@@ -29,9 +41,19 @@ export default function ContactPage() {
|
||||
<Section
|
||||
eyebrow="Enquiry form"
|
||||
title="Send a message"
|
||||
description="This is the public contact entry point for questions that do not need a booking decision yet."
|
||||
description={
|
||||
property
|
||||
? `This enquiry form is pre-routed for ${property} if you want to ask a question before booking.`
|
||||
: 'This is the public contact entry point for questions that do not need a booking decision yet.'
|
||||
}
|
||||
>
|
||||
<form className="contact-form">
|
||||
{property ? (
|
||||
<label>
|
||||
<span>Property</span>
|
||||
<input type="text" name="property" defaultValue={property} readOnly />
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" placeholder="Your name" />
|
||||
@@ -46,7 +68,7 @@ export default function ContactPage() {
|
||||
</label>
|
||||
<label className="contact-form-message">
|
||||
<span>Message</span>
|
||||
<textarea name="message" rows={6} placeholder="Tell us a little about your stay or question." />
|
||||
<textarea name="message" rows={6} placeholder={messagePlaceholder} />
|
||||
</label>
|
||||
<button className="btn btn-dark" type="button">
|
||||
Send enquiry
|
||||
|
||||
@@ -290,6 +290,7 @@ main {
|
||||
}
|
||||
|
||||
.search-field input,
|
||||
.search-field select,
|
||||
.contact-form input,
|
||||
.contact-form textarea,
|
||||
.contact-form select {
|
||||
@@ -437,6 +438,16 @@ main {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.property-card-image {
|
||||
display: block;
|
||||
width: calc(100% + 2rem);
|
||||
max-width: none;
|
||||
margin: -1rem -1rem 0;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 1.35rem 1.35rem 0 0;
|
||||
}
|
||||
|
||||
.property-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -573,6 +584,79 @@ main {
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.property-hero,
|
||||
.property-layout {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.property-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.85fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.property-callout {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid rgba(46, 102, 97, 0.14);
|
||||
border-radius: 1.35rem;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 248, 247, 0.9));
|
||||
}
|
||||
|
||||
.property-callout strong {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.property-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.property-gallery-card {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
min-height: 220px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 1.35rem;
|
||||
background: rgba(244, 239, 231, 0.88);
|
||||
}
|
||||
|
||||
.property-gallery-card.is-primary {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.property-gallery-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.policy-grid,
|
||||
.availability-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.policy-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.availability-item {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(244, 239, 231, 0.88);
|
||||
}
|
||||
|
||||
.availability-item span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-hero {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -677,6 +761,66 @@ main {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.property-directory-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.property-directory-form {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.search-field-wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.toggle-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid rgba(26, 23, 20, 0.08);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle-field input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-actions,
|
||||
.section-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.property-results-grid {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.property-result-card {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.directory-filter-list {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.availability-notes {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.empty-state-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
@@ -719,12 +863,14 @@ main {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.property-hero,
|
||||
.phase-grid,
|
||||
.data-grid,
|
||||
.property-grid,
|
||||
.content-grid,
|
||||
.testimonial-grid,
|
||||
.page-layout {
|
||||
.page-layout,
|
||||
.search-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -761,9 +907,14 @@ main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.policy-grid,
|
||||
.property-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-field-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { Section } from '@/components/Section';
|
||||
import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking';
|
||||
import { getFallbackPropertyDirectory } from '@/lib/properties';
|
||||
import {
|
||||
featuredProperties,
|
||||
locationHighlights,
|
||||
site,
|
||||
testimonials,
|
||||
@@ -14,13 +14,6 @@ const ctaPoints = [
|
||||
'A direct contact route for questions before booking',
|
||||
];
|
||||
|
||||
const bookingFields = [
|
||||
{ label: 'Arrival', value: 'Choose a date' },
|
||||
{ label: 'Departure', value: 'Choose a date' },
|
||||
{ label: 'Guests', value: '2 adults' },
|
||||
{ label: 'Area', value: 'Coastal or rural' },
|
||||
];
|
||||
|
||||
const demoQuote = quoteStay(bookingCatalog[0]!, {
|
||||
arrivalDate: '2026-07-10',
|
||||
departureDate: '2026-07-14',
|
||||
@@ -29,7 +22,9 @@ const demoQuote = quoteStay(bookingCatalog[0]!, {
|
||||
pets: 0,
|
||||
});
|
||||
|
||||
export default function HomePage() {
|
||||
export default async function HomePage() {
|
||||
const featuredProperties = getFallbackPropertyDirectory().filter((property) => property.featured);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="hero" id="top">
|
||||
@@ -39,8 +34,8 @@ export default function HomePage() {
|
||||
<p>{site.description}</p>
|
||||
|
||||
<div className="hero-actions">
|
||||
<Link className="btn btn-primary" href="#browse">
|
||||
Explore featured stays
|
||||
<Link className="btn btn-primary" href="/properties">
|
||||
Browse all properties
|
||||
</Link>
|
||||
<Link className="btn btn-outline-dark" href="/contact">
|
||||
Contact the team
|
||||
@@ -59,20 +54,30 @@ export default function HomePage() {
|
||||
<p className="footer-label">Search preview</p>
|
||||
<strong>Plan the right stay</strong>
|
||||
<p className="mb-0 text-body-secondary">
|
||||
The booking flow will later use live availability and pricing. This slice keeps the public browsing entry point clear.
|
||||
Search now leads into the live property directory, with filters that reuse the same availability and pricing rules as the booking flow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="search-panel" aria-label="Availability search preview">
|
||||
{bookingFields.map((field) => (
|
||||
<label key={field.label} className="search-field">
|
||||
<span>{field.label}</span>
|
||||
<input aria-label={field.label} defaultValue={field.value} />
|
||||
</label>
|
||||
))}
|
||||
<Link className="btn btn-dark" href="/bookings/new">
|
||||
Check availability
|
||||
</Link>
|
||||
<form className="search-panel" aria-label="Availability search" action="/properties">
|
||||
<label className="search-field">
|
||||
<span>Arrival</span>
|
||||
<input aria-label="Arrival" type="date" name="arrivalDate" defaultValue="2026-07-10" />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Departure</span>
|
||||
<input aria-label="Departure" type="date" name="departureDate" defaultValue="2026-07-14" />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Adults</span>
|
||||
<input aria-label="Adults" type="number" name="adults" min="1" max="8" defaultValue="2" />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Area or property</span>
|
||||
<input aria-label="Area or property" type="text" name="location" defaultValue="Coastal" />
|
||||
</label>
|
||||
<button className="btn btn-dark" type="submit">
|
||||
Search stays
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="quote-panel" aria-label="Booking quote preview">
|
||||
@@ -126,12 +131,17 @@ export default function HomePage() {
|
||||
<div className="property-grid">
|
||||
{featuredProperties.map((property) => (
|
||||
<article key={property.slug} className="property-card">
|
||||
{property.image ? <img className="property-card-image" src={property.image.url} alt={property.image.altText} /> : null}
|
||||
<div className="property-card-top">
|
||||
<div>
|
||||
<p className="footer-label">{property.area}</p>
|
||||
<h3>{property.name}</h3>
|
||||
<h3>
|
||||
<Link href={`/properties/${property.slug}`}>{property.title}</Link>
|
||||
</h3>
|
||||
</div>
|
||||
<span className="property-price">{property.priceFrom}</span>
|
||||
<span className="property-price">
|
||||
{property.priceFromCents ? `From ${formatPoundsFromCents(property.priceFromCents)}/night` : 'Price on request'}
|
||||
</span>
|
||||
</div>
|
||||
<p>{property.summary}</p>
|
||||
<dl className="property-metrics">
|
||||
@@ -149,13 +159,21 @@ export default function HomePage() {
|
||||
</div>
|
||||
</dl>
|
||||
<ul className="tag-list">
|
||||
{property.tags.map((tag) => (
|
||||
{property.marketingTags.map((tag) => (
|
||||
<li key={tag}>{tag}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="inline-link" href={`/properties/${property.slug}`}>
|
||||
View property details
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<Link className="btn btn-outline-dark" href="/properties">
|
||||
Open the full property directory
|
||||
</Link>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
|
||||
238
src/app/properties/[slug]/page.tsx
Normal file
238
src/app/properties/[slug]/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Section } from '@/components/Section';
|
||||
import { buildFallbackProperty, getPublishedPropertyBySlug } from '@/lib/properties';
|
||||
import { site } from '@/lib/site';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type PropertyPageProps = {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function formatPounds(cents: number) {
|
||||
return new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'GBP',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatReason(reason: string) {
|
||||
return reason
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function fallbackMetadata(slug: string) {
|
||||
const seeded = buildFallbackProperty(slug);
|
||||
if (!seeded) {
|
||||
return {
|
||||
title: site.name,
|
||||
description: site.description,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${seeded.title} | ${site.name}`,
|
||||
description: seeded.summary,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PropertyPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
|
||||
try {
|
||||
const property = await getPublishedPropertyBySlug(slug);
|
||||
if (!property) {
|
||||
return fallbackMetadata(slug);
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${property.title} | ${site.name}`,
|
||||
description: property.summary,
|
||||
};
|
||||
} catch {
|
||||
return fallbackMetadata(slug);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PropertyDetailPage({ params }: PropertyPageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
const property = (await getPublishedPropertyBySlug(slug)) ?? buildFallbackProperty(slug);
|
||||
if (!property) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const primaryImage = property.images[0] ?? null;
|
||||
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||
const seasonalRates = property.pricingRules.filter((rule) => rule.validFrom && rule.validTo);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-hero property-hero">
|
||||
<div>
|
||||
<p className="brand-kicker">Property detail</p>
|
||||
<h2>{property.title}</h2>
|
||||
<p>{property.summary}</p>
|
||||
<div className="hero-actions">
|
||||
<Link className="btn btn-primary" href={`/bookings/new?propertySlug=${property.slug}`}>
|
||||
Check availability
|
||||
</Link>
|
||||
<Link className="btn btn-outline-dark" href={`/contact?property=${encodeURIComponent(property.title)}`}>
|
||||
Ask a question first
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article className="property-callout">
|
||||
<p className="footer-label">Booking context</p>
|
||||
<strong>{standardRate ? `From ${formatPounds(standardRate.basePriceCents)} per night` : 'Price on request'}</strong>
|
||||
<p>{property.locationText}</p>
|
||||
<dl className="property-metrics">
|
||||
<div>
|
||||
<dt>Sleeps</dt>
|
||||
<dd>{property.sleeps}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bedrooms</dt>
|
||||
<dd>{property.bedrooms}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bathrooms</dt>
|
||||
<dd>{property.bathrooms}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="page-layout property-layout">
|
||||
<Section
|
||||
eyebrow="Gallery and overview"
|
||||
title="Everything a guest needs before starting the booking flow"
|
||||
description="This page is intentionally practical first: imagery, essentials, stay rules, and a clear route into booking or enquiry."
|
||||
>
|
||||
<div className="property-gallery" aria-label={`${property.title} gallery`}>
|
||||
{property.images.map((image) => (
|
||||
<figure key={image.id} className={`property-gallery-card ${image.primaryImage ? 'is-primary' : ''}`}>
|
||||
<img src={image.url} alt={image.altText} />
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="content-stack">
|
||||
<article className="content-card">
|
||||
<h3>About this stay</h3>
|
||||
<p>{property.longDescription}</p>
|
||||
</article>
|
||||
|
||||
<article className="content-card">
|
||||
<h3>Amenities</h3>
|
||||
<ul className="tag-list">
|
||||
{property.amenities.map((item) => (
|
||||
<li key={item.amenityId}>{item.amenity.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="content-card">
|
||||
<h3>Stay policies and practical details</h3>
|
||||
<div className="policy-grid">
|
||||
<div className="metric">
|
||||
<strong>{property.minStayNights}</strong>
|
||||
<span>Minimum nights</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<strong>{property.checkInTime ?? '16:00'}</strong>
|
||||
<span>Check-in</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<strong>{property.checkOutTime ?? '10:00'}</strong>
|
||||
<span>Check-out</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<strong>{property.petsAllowed ? 'Allowed' : 'Not allowed'}</strong>
|
||||
<span>Pets</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<aside className="content-sidebar">
|
||||
<article className="content-card">
|
||||
<p className="footer-label">Primary image</p>
|
||||
<h3>{primaryImage ? primaryImage.altText : 'Property overview'}</h3>
|
||||
<p className="mb-0">
|
||||
{primaryImage ? 'The first gallery image sets the main expectation for the stay.' : property.summary}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="content-card">
|
||||
<h3>Availability and pricing context</h3>
|
||||
<ul className="admin-bullet-list">
|
||||
<li>{standardRate ? `Base nightly rate: ${formatPounds(standardRate.basePriceCents)}` : 'Rate available on request'}</li>
|
||||
<li>{standardRate?.weekendPriceCents ? `Weekend rate: ${formatPounds(standardRate.weekendPriceCents)}` : 'Weekend pricing follows the base rate'}</li>
|
||||
<li>{standardRate?.guestDeltaCents ? `Additional guest supplement: ${formatPounds(standardRate.guestDeltaCents)} per night` : 'No guest supplement applies'}</li>
|
||||
</ul>
|
||||
{seasonalRates.length ? (
|
||||
<div className="availability-list">
|
||||
{seasonalRates.map((rule) => (
|
||||
<div key={rule.id} className="availability-item">
|
||||
<strong>{rule.label || 'Seasonal rate'}</strong>
|
||||
<span>
|
||||
{formatDate(rule.validFrom as Date)} to {formatDate(rule.validTo as Date)} · {formatPounds(rule.basePriceCents)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
<article className="content-card">
|
||||
<h3>Known unavailable dates</h3>
|
||||
<div className="availability-list">
|
||||
{property.availability.length ? (
|
||||
property.availability.map((block) => (
|
||||
<div key={block.id} className="availability-item">
|
||||
<strong>{formatReason(block.reason)}</strong>
|
||||
<span>
|
||||
{formatDate(block.startDate)} to {formatDate(block.endDate)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="mb-0">No blocked dates are currently published for this property.</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="content-card">
|
||||
<h3>Guest feedback</h3>
|
||||
<div className="content-stack">
|
||||
{property.testimonials.map((testimonial) => (
|
||||
<blockquote key={testimonial.id} className="testimonial-card">
|
||||
<p>{testimonial.content}</p>
|
||||
<footer>
|
||||
<strong>{testimonial.authorName}</strong>
|
||||
<span>{testimonial.rating ? `${testimonial.rating}/5 rating` : 'Published guest feedback'}</span>
|
||||
</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
303
src/app/properties/page.tsx
Normal file
303
src/app/properties/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import Link from 'next/link';
|
||||
import { Section } from '@/components/Section';
|
||||
import { formatPoundsFromCents, searchBookings } from '@/lib/booking';
|
||||
import { getFallbackPropertyDirectory } from '@/lib/properties';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type PropertiesPageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function firstValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function parseNumber(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseAmenityList(value: string | undefined) {
|
||||
return value
|
||||
? value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
export default async function PropertiesPage({ searchParams }: PropertiesPageProps) {
|
||||
const params = (searchParams ? await searchParams : {}) ?? {};
|
||||
const location = firstValue(params.location)?.trim() ?? '';
|
||||
const rawArrivalDate = firstValue(params.arrivalDate);
|
||||
const rawDepartureDate = firstValue(params.departureDate);
|
||||
const rawAdults = firstValue(params.adults);
|
||||
const rawChildren = firstValue(params.children);
|
||||
const rawPets = firstValue(params.pets);
|
||||
const rawBedrooms = firstValue(params.bedrooms);
|
||||
const rawAmenities = firstValue(params.amenities);
|
||||
const rawPetsAllowed = firstValue(params.petsAllowed);
|
||||
const arrivalDate = rawArrivalDate ?? '';
|
||||
const departureDate = rawDepartureDate ?? '';
|
||||
const adults = parseNumber(rawAdults, 2);
|
||||
const children = parseNumber(rawChildren, 0);
|
||||
const pets = parseNumber(rawPets, 0);
|
||||
const minimumBedrooms = parseNumber(rawBedrooms, 0);
|
||||
const requiredAmenities = parseAmenityList(rawAmenities);
|
||||
const petsOnly = rawPetsAllowed === 'true';
|
||||
const hasDateSearch = Boolean(arrivalDate && departureDate);
|
||||
|
||||
const directory = getFallbackPropertyDirectory();
|
||||
const directoryBySlug = new Map(directory.map((property) => [property.slug, property]));
|
||||
const availableAmenities = [...new Set(directory.flatMap((property) => property.amenities))].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const search = searchBookings({
|
||||
arrivalDate: arrivalDate || undefined,
|
||||
departureDate: departureDate || undefined,
|
||||
adults,
|
||||
children,
|
||||
pets,
|
||||
location: location || undefined,
|
||||
});
|
||||
|
||||
const results = search.results.filter((result) => {
|
||||
const property = directoryBySlug.get(result.propertySlug);
|
||||
if (!property) return false;
|
||||
if (minimumBedrooms > 0 && property.bedrooms < minimumBedrooms) return false;
|
||||
if (petsOnly && !property.petsAllowed) return false;
|
||||
if (requiredAmenities.length > 0 && !requiredAmenities.every((amenity) => property.amenities.includes(amenity))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeFilters = [
|
||||
location ? `Area or property: ${location}` : null,
|
||||
hasDateSearch ? `Dates: ${arrivalDate} to ${departureDate}` : null,
|
||||
rawAdults ? `Adults: ${adults}` : null,
|
||||
rawChildren ? `Children: ${children}` : null,
|
||||
rawPets ? `Pets: ${pets}` : null,
|
||||
rawBedrooms ? `Bedrooms: ${minimumBedrooms}+` : null,
|
||||
petsOnly ? 'Pet-friendly only' : null,
|
||||
...requiredAmenities.map((amenity) => `Amenity: ${amenity}`),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-hero property-directory-hero">
|
||||
<div>
|
||||
<p className="brand-kicker">Property directory</p>
|
||||
<h2>Search live stay rules before the booking form starts</h2>
|
||||
<p>
|
||||
This directory uses the actual property inventory and booking-rule core so guests can narrow the list by dates,
|
||||
guest mix, pet rules, bedrooms, and practical stay features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article className="property-callout">
|
||||
<p className="footer-label">What this page covers</p>
|
||||
<strong>{results.length} properties in view</strong>
|
||||
<p>
|
||||
Available stays rise to the top when dates are present, and every card keeps the enquiry and booking routes
|
||||
visible.
|
||||
</p>
|
||||
<dl className="property-metrics">
|
||||
<div>
|
||||
<dt>Filters</dt>
|
||||
<dd>Dates, guests, pets, bedrooms, amenities</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Sorting</dt>
|
||||
<dd>Availability first, then lowest total</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="page-layout property-directory-layout">
|
||||
<Section
|
||||
eyebrow="Search controls"
|
||||
title="Guests can refine the directory without losing the booking context"
|
||||
description="The filters stay in the open and submit as plain query parameters so links are shareable and the page keeps working without client-side state."
|
||||
>
|
||||
<form className="search-panel property-directory-form" aria-label="Property directory search">
|
||||
<div className="search-grid">
|
||||
<label className="search-field">
|
||||
<span>Arrival</span>
|
||||
<input type="date" name="arrivalDate" defaultValue={arrivalDate} />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Departure</span>
|
||||
<input type="date" name="departureDate" defaultValue={departureDate} />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Adults</span>
|
||||
<input type="number" name="adults" min="1" max="8" defaultValue={adults} />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Children</span>
|
||||
<input type="number" name="children" min="0" max="8" defaultValue={children} />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Pets</span>
|
||||
<input type="number" name="pets" min="0" max="4" defaultValue={pets} />
|
||||
</label>
|
||||
<label className="search-field">
|
||||
<span>Bedrooms</span>
|
||||
<input type="number" name="bedrooms" min="0" max="8" defaultValue={minimumBedrooms} />
|
||||
</label>
|
||||
<label className="search-field search-field-wide">
|
||||
<span>Area or property</span>
|
||||
<input type="text" name="location" defaultValue={location} placeholder="Coastal, harbour, rural..." />
|
||||
</label>
|
||||
<label className="toggle-field">
|
||||
<input type="checkbox" name="petsAllowed" value="true" defaultChecked={petsOnly} />
|
||||
<span>Show pet-friendly properties only</span>
|
||||
</label>
|
||||
<label className="search-field search-field-wide">
|
||||
<span>Amenities</span>
|
||||
<select name="amenities" defaultValue={requiredAmenities.join(',')}>
|
||||
<option value="">Any amenity mix</option>
|
||||
{availableAmenities.map((amenity) => (
|
||||
<option key={amenity} value={amenity}>
|
||||
{amenity}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="search-actions">
|
||||
<button className="btn btn-dark" type="submit">
|
||||
Search stays
|
||||
</button>
|
||||
<Link className="btn btn-outline-dark" href="/properties">
|
||||
Clear filters
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
eyebrow="Results"
|
||||
title="Availability signals stay attached to each property card"
|
||||
description="The card order and booking summaries are driven by the same quote logic used later in the checkout path."
|
||||
>
|
||||
{activeFilters.length ? (
|
||||
<ul className="tag-list directory-filter-list" aria-label="Active filters">
|
||||
{activeFilters.map((filter) => (
|
||||
<li key={filter}>{filter}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="property-grid property-results-grid">
|
||||
{results.map((result) => {
|
||||
const property = directoryBySlug.get(result.propertySlug);
|
||||
if (!property) return null;
|
||||
|
||||
return (
|
||||
<article key={result.propertySlug} className="property-card property-result-card">
|
||||
{property.image ? <img className="property-card-image" src={property.image.url} alt={property.image.altText} /> : null}
|
||||
<div className="property-card-top">
|
||||
<div>
|
||||
<p className="footer-label">{property.area}</p>
|
||||
<h3>
|
||||
<Link href={`/properties/${result.propertySlug}`}>{result.propertyName}</Link>
|
||||
</h3>
|
||||
</div>
|
||||
<span className="property-price">
|
||||
{result.totalCents > 0
|
||||
? formatPoundsFromCents(result.totalCents)
|
||||
: property.priceFromCents
|
||||
? `From ${formatPoundsFromCents(property.priceFromCents)}/night`
|
||||
: 'Price on request'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`availability-pill ${
|
||||
hasDateSearch ? (result.available ? 'is-available' : 'is-unavailable') : ''
|
||||
}`}
|
||||
>
|
||||
{hasDateSearch
|
||||
? result.available
|
||||
? 'Available for these dates'
|
||||
: 'Check result details'
|
||||
: 'Browse property overview'}
|
||||
</div>
|
||||
|
||||
<p>{property.summary}</p>
|
||||
<dl className="property-metrics">
|
||||
<div>
|
||||
<dt>Sleeps</dt>
|
||||
<dd>{property.sleeps}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bedrooms</dt>
|
||||
<dd>{property.bedrooms}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bathrooms</dt>
|
||||
<dd>{property.bathrooms}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<ul className="tag-list">
|
||||
{property.marketingTags.map((tag) => (
|
||||
<li key={tag}>{tag}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{hasDateSearch && result.reasons.length ? (
|
||||
<ul className="admin-bullet-list availability-notes">
|
||||
{result.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : hasDateSearch ? (
|
||||
<div className="availability-list">
|
||||
<div className="availability-item">
|
||||
<strong>{result.nights} nights</strong>
|
||||
<span>
|
||||
{result.arrivalDate} to {result.departureDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="availability-list">
|
||||
<div className="availability-item">
|
||||
<strong>{property.minStayNights} night minimum stay</strong>
|
||||
<span>{property.locationText}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hero-actions">
|
||||
<Link className="btn btn-primary" href={`/bookings/new?propertySlug=${result.propertySlug}`}>
|
||||
Start booking
|
||||
</Link>
|
||||
<Link className="btn btn-outline-dark" href={`/properties/${result.propertySlug}`}>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<article className="content-card empty-state-card">
|
||||
<h3>No properties matched this combination</h3>
|
||||
<p>
|
||||
Try fewer filters or remove the amenity and bedroom limits first. The listing keeps your current query in
|
||||
the URL so it is easy to adjust and retry.
|
||||
</p>
|
||||
</article>
|
||||
) : null}
|
||||
</Section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { propertySeedData } from '@/lib/propertySeedData';
|
||||
|
||||
export type BookingSearchInput = {
|
||||
arrivalDate?: string;
|
||||
departureDate?: string;
|
||||
@@ -68,87 +70,40 @@ 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' }],
|
||||
},
|
||||
...propertySeedData.map((property) => {
|
||||
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||
|
||||
return {
|
||||
slug: property.slug,
|
||||
name: property.title,
|
||||
area: property.area,
|
||||
summary: property.summary,
|
||||
sleeps: property.sleeps,
|
||||
bedrooms: property.bedrooms,
|
||||
bathrooms: property.bathrooms,
|
||||
published: true,
|
||||
petsAllowed: property.petsAllowed,
|
||||
minStayNights: property.minStayNights,
|
||||
baseNightlyCents: standardRate?.basePriceCents ?? 0,
|
||||
weekendNightlyCents: standardRate?.weekendPriceCents,
|
||||
guestSupplementCents: standardRate?.guestDeltaCents,
|
||||
seasonalRates: property.pricingRules
|
||||
.filter((rule) => rule.validFrom && rule.validTo)
|
||||
.map((rule) => ({
|
||||
label: rule.label || 'Seasonal rate',
|
||||
startDate: rule.validFrom || '',
|
||||
endDate: rule.validTo || '',
|
||||
nightlyCents: rule.basePriceCents,
|
||||
weekendNightlyCents: rule.weekendPriceCents,
|
||||
})),
|
||||
availabilityBlocks: property.availabilityBlocks.map((block) => ({
|
||||
startDate: block.startDate,
|
||||
endDate: block.endDate,
|
||||
reason: block.reason,
|
||||
})),
|
||||
confirmedBookings: [],
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
function parseDate(value?: string) {
|
||||
|
||||
344
src/lib/properties.ts
Normal file
344
src/lib/properties.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { propertySeedData } from '@/lib/propertySeedData';
|
||||
|
||||
const propertyInclude = {
|
||||
amenities: {
|
||||
include: {
|
||||
amenity: true,
|
||||
},
|
||||
},
|
||||
images: {
|
||||
orderBy: [{ primaryImage: 'desc' }, { displayOrder: 'asc' }],
|
||||
},
|
||||
pricingRules: {
|
||||
orderBy: [{ validFrom: 'asc' }, { createdAt: 'asc' }],
|
||||
},
|
||||
availability: {
|
||||
orderBy: { startDate: 'asc' },
|
||||
},
|
||||
testimonials: {
|
||||
where: { published: true },
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
},
|
||||
} satisfies Prisma.PropertyInclude;
|
||||
|
||||
export type PropertyDetailRecord = Prisma.PropertyGetPayload<{
|
||||
include: typeof propertyInclude;
|
||||
}>;
|
||||
|
||||
export type FallbackPropertyDetail = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
area: string;
|
||||
summary: string;
|
||||
longDescription: string;
|
||||
locationText: string;
|
||||
sleeps: number;
|
||||
bedrooms: number;
|
||||
bathrooms: number;
|
||||
petsAllowed: boolean;
|
||||
published: boolean;
|
||||
featured: boolean;
|
||||
minStayNights: number;
|
||||
checkInTime: string;
|
||||
checkOutTime: string;
|
||||
marketingTags: string[];
|
||||
images: Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
altText: string;
|
||||
primaryImage: boolean;
|
||||
}>;
|
||||
amenities: Array<{
|
||||
amenityId: string;
|
||||
amenity: {
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
pricingRules: Array<{
|
||||
id: string;
|
||||
label: string | null;
|
||||
basePriceCents: number;
|
||||
weekendPriceCents: number | null;
|
||||
guestDeltaCents: number | null;
|
||||
validFrom: Date | null;
|
||||
validTo: Date | null;
|
||||
}>;
|
||||
availability: Array<{
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
reason: string;
|
||||
notes: string | null;
|
||||
}>;
|
||||
testimonials: Array<{
|
||||
id: string;
|
||||
authorName: string;
|
||||
content: string;
|
||||
rating: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PropertyDirectoryEntry = {
|
||||
slug: string;
|
||||
title: string;
|
||||
area: string;
|
||||
summary: string;
|
||||
locationText: string;
|
||||
sleeps: number;
|
||||
bedrooms: number;
|
||||
bathrooms: number;
|
||||
petsAllowed: boolean;
|
||||
featured: boolean;
|
||||
minStayNights: number;
|
||||
marketingTags: string[];
|
||||
image: {
|
||||
url: string;
|
||||
altText: string;
|
||||
} | null;
|
||||
priceFromCents: number | null;
|
||||
amenities: string[];
|
||||
};
|
||||
|
||||
function slugifyAmenity(name: string) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function seedPropertyInventory() {
|
||||
const amenityIdByName = new Map<string, string>();
|
||||
|
||||
for (const amenityName of new Set(propertySeedData.flatMap((property) => property.amenities))) {
|
||||
const amenity = await prisma.amenity.upsert({
|
||||
where: { slug: slugifyAmenity(amenityName) },
|
||||
create: {
|
||||
slug: slugifyAmenity(amenityName),
|
||||
name: amenityName,
|
||||
},
|
||||
update: {
|
||||
name: amenityName,
|
||||
},
|
||||
});
|
||||
|
||||
amenityIdByName.set(amenityName, amenity.id);
|
||||
}
|
||||
|
||||
for (const property of propertySeedData) {
|
||||
const record = await prisma.property.upsert({
|
||||
where: { slug: property.slug },
|
||||
create: {
|
||||
slug: property.slug,
|
||||
title: property.title,
|
||||
summary: property.summary,
|
||||
longDescription: property.longDescription,
|
||||
locationText: property.locationText,
|
||||
sleeps: property.sleeps,
|
||||
bedrooms: property.bedrooms,
|
||||
bathrooms: property.bathrooms,
|
||||
petsAllowed: property.petsAllowed,
|
||||
published: true,
|
||||
featured: property.featured,
|
||||
minStayNights: property.minStayNights,
|
||||
checkInTime: property.checkInTime,
|
||||
checkOutTime: property.checkOutTime,
|
||||
},
|
||||
update: {
|
||||
title: property.title,
|
||||
summary: property.summary,
|
||||
longDescription: property.longDescription,
|
||||
locationText: property.locationText,
|
||||
sleeps: property.sleeps,
|
||||
bedrooms: property.bedrooms,
|
||||
bathrooms: property.bathrooms,
|
||||
petsAllowed: property.petsAllowed,
|
||||
published: true,
|
||||
featured: property.featured,
|
||||
minStayNights: property.minStayNights,
|
||||
checkInTime: property.checkInTime,
|
||||
checkOutTime: property.checkOutTime,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.propertyAmenity.deleteMany({ where: { propertyId: record.id } });
|
||||
await prisma.propertyImage.deleteMany({ where: { propertyId: record.id } });
|
||||
await prisma.pricingRule.deleteMany({ where: { propertyId: record.id } });
|
||||
await prisma.availabilityBlock.deleteMany({ where: { propertyId: record.id } });
|
||||
await prisma.testimonial.deleteMany({ where: { propertyId: record.id } });
|
||||
|
||||
await prisma.propertyAmenity.createMany({
|
||||
data: property.amenities.map((amenityName) => ({
|
||||
propertyId: record.id,
|
||||
amenityId: amenityIdByName.get(amenityName) || '',
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.propertyImage.createMany({
|
||||
data: property.images.map((image, imageIndex) => ({
|
||||
propertyId: record.id,
|
||||
url: image.url,
|
||||
altText: image.altText,
|
||||
displayOrder: imageIndex,
|
||||
primaryImage: image.primaryImage ?? imageIndex === 0,
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.pricingRule.createMany({
|
||||
data: property.pricingRules.map((rule) => ({
|
||||
propertyId: record.id,
|
||||
label: rule.label || null,
|
||||
basePriceCents: rule.basePriceCents,
|
||||
weekendPriceCents: rule.weekendPriceCents ?? null,
|
||||
guestDeltaCents: rule.guestDeltaCents ?? null,
|
||||
validFrom: rule.validFrom ? new Date(`${rule.validFrom}T00:00:00.000Z`) : null,
|
||||
validTo: rule.validTo ? new Date(`${rule.validTo}T00:00:00.000Z`) : null,
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.availabilityBlock.createMany({
|
||||
data: property.availabilityBlocks.map((block) => ({
|
||||
propertyId: record.id,
|
||||
startDate: new Date(`${block.startDate}T00:00:00.000Z`),
|
||||
endDate: new Date(`${block.endDate}T00:00:00.000Z`),
|
||||
reason: block.reason,
|
||||
notes: block.notes ?? null,
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.testimonial.createMany({
|
||||
data: property.testimonials.map((testimonial, testimonialIndex) => ({
|
||||
propertyId: record.id,
|
||||
authorName: testimonial.authorName,
|
||||
content: testimonial.content,
|
||||
rating: testimonial.rating ?? null,
|
||||
published: true,
|
||||
displayOrder: testimonialIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSeededProperties() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const seededCount = await prisma.property.count({
|
||||
where: {
|
||||
slug: {
|
||||
in: propertySeedData.map((property) => property.slug),
|
||||
},
|
||||
},
|
||||
});
|
||||
const imageCount = await prisma.propertyImage.count();
|
||||
|
||||
if (seededCount === propertySeedData.length && imageCount > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await seedPropertyInventory();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getPublishedPropertyBySlug(slug: string) {
|
||||
const hasDatabase = await ensureSeededProperties();
|
||||
if (!hasDatabase) return null;
|
||||
|
||||
return prisma.property.findFirst({
|
||||
where: { slug, published: true },
|
||||
include: propertyInclude,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFallbackProperty(slug: string): FallbackPropertyDetail | null {
|
||||
const seeded = propertySeedData.find((property) => property.slug === slug);
|
||||
if (!seeded) return null;
|
||||
|
||||
return {
|
||||
id: seeded.slug,
|
||||
slug: seeded.slug,
|
||||
title: seeded.title,
|
||||
area: seeded.area,
|
||||
summary: seeded.summary,
|
||||
longDescription: seeded.longDescription,
|
||||
locationText: seeded.locationText,
|
||||
sleeps: seeded.sleeps,
|
||||
bedrooms: seeded.bedrooms,
|
||||
bathrooms: seeded.bathrooms,
|
||||
petsAllowed: seeded.petsAllowed,
|
||||
published: true,
|
||||
featured: seeded.featured,
|
||||
minStayNights: seeded.minStayNights,
|
||||
checkInTime: seeded.checkInTime,
|
||||
checkOutTime: seeded.checkOutTime,
|
||||
marketingTags: seeded.marketingTags,
|
||||
images: seeded.images.map((image, index) => ({
|
||||
id: `${seeded.slug}-image-${index}`,
|
||||
url: image.url,
|
||||
altText: image.altText,
|
||||
primaryImage: image.primaryImage ?? index === 0,
|
||||
})),
|
||||
amenities: seeded.amenities.map((amenity, index) => ({
|
||||
amenityId: `${seeded.slug}-amenity-${index}`,
|
||||
amenity: {
|
||||
name: amenity,
|
||||
},
|
||||
})),
|
||||
pricingRules: seeded.pricingRules.map((rule, index) => ({
|
||||
id: `${seeded.slug}-pricing-${index}`,
|
||||
label: rule.label || null,
|
||||
basePriceCents: rule.basePriceCents,
|
||||
weekendPriceCents: rule.weekendPriceCents ?? null,
|
||||
guestDeltaCents: rule.guestDeltaCents ?? null,
|
||||
validFrom: rule.validFrom ? new Date(`${rule.validFrom}T00:00:00.000Z`) : null,
|
||||
validTo: rule.validTo ? new Date(`${rule.validTo}T00:00:00.000Z`) : null,
|
||||
})),
|
||||
availability: seeded.availabilityBlocks.map((block, index) => ({
|
||||
id: `${seeded.slug}-availability-${index}`,
|
||||
startDate: new Date(`${block.startDate}T00:00:00.000Z`),
|
||||
endDate: new Date(`${block.endDate}T00:00:00.000Z`),
|
||||
reason: block.reason,
|
||||
notes: block.notes ?? null,
|
||||
})),
|
||||
testimonials: seeded.testimonials.map((testimonial, index) => ({
|
||||
id: `${seeded.slug}-testimonial-${index}`,
|
||||
authorName: testimonial.authorName,
|
||||
content: testimonial.content,
|
||||
rating: testimonial.rating ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function getFallbackPropertyDirectory(): PropertyDirectoryEntry[] {
|
||||
return propertySeedData.map((property) => {
|
||||
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||
const primaryImage =
|
||||
property.images.find((image) => image.primaryImage) ?? property.images[0] ?? null;
|
||||
|
||||
return {
|
||||
slug: property.slug,
|
||||
title: property.title,
|
||||
area: property.area,
|
||||
summary: property.summary,
|
||||
locationText: property.locationText,
|
||||
sleeps: property.sleeps,
|
||||
bedrooms: property.bedrooms,
|
||||
bathrooms: property.bathrooms,
|
||||
petsAllowed: property.petsAllowed,
|
||||
featured: property.featured,
|
||||
minStayNights: property.minStayNights,
|
||||
marketingTags: property.marketingTags,
|
||||
image: primaryImage
|
||||
? {
|
||||
url: primaryImage.url,
|
||||
altText: primaryImage.altText,
|
||||
}
|
||||
: null,
|
||||
priceFromCents: standardRate?.basePriceCents ?? null,
|
||||
amenities: property.amenities,
|
||||
};
|
||||
});
|
||||
}
|
||||
258
src/lib/propertySeedData.ts
Normal file
258
src/lib/propertySeedData.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
type SeedImage = {
|
||||
url: string;
|
||||
altText: string;
|
||||
primaryImage?: boolean;
|
||||
};
|
||||
|
||||
type SeedPricingRule = {
|
||||
label?: string;
|
||||
basePriceCents: number;
|
||||
weekendPriceCents?: number;
|
||||
guestDeltaCents?: number;
|
||||
validFrom?: string;
|
||||
validTo?: string;
|
||||
};
|
||||
|
||||
type SeedAvailabilityBlock = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: 'MAINTENANCE' | 'OWNER_BLOCKED' | 'BASE_RULE' | 'OTHER';
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
type SeedTestimonial = {
|
||||
authorName: string;
|
||||
content: string;
|
||||
rating?: number;
|
||||
};
|
||||
|
||||
export type SeedProperty = {
|
||||
slug: string;
|
||||
title: string;
|
||||
area: string;
|
||||
summary: string;
|
||||
longDescription: string;
|
||||
locationText: string;
|
||||
sleeps: number;
|
||||
bedrooms: number;
|
||||
bathrooms: number;
|
||||
petsAllowed: boolean;
|
||||
featured: boolean;
|
||||
minStayNights: number;
|
||||
checkInTime: string;
|
||||
checkOutTime: string;
|
||||
marketingTags: string[];
|
||||
images: SeedImage[];
|
||||
amenities: string[];
|
||||
pricingRules: SeedPricingRule[];
|
||||
availabilityBlocks: SeedAvailabilityBlock[];
|
||||
testimonials: SeedTestimonial[];
|
||||
};
|
||||
|
||||
export const propertySeedData: SeedProperty[] = [
|
||||
{
|
||||
slug: 'coastal-view-cottage',
|
||||
title: 'Coastal View Cottage',
|
||||
area: 'Clifftop village',
|
||||
summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.',
|
||||
longDescription:
|
||||
'Coastal View Cottage is set above the bay with a sunny terrace, two calm bedrooms, and a living space designed for slower mornings after coastal walks. The stay is positioned for guests who want practical comfort first, then the sea view to do the rest of the work.',
|
||||
locationText:
|
||||
'The cottage sits in a clifftop village a short walk from the harbour path, local bakery, and a small beach reached by steps down the headland.',
|
||||
sleeps: 4,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
petsAllowed: false,
|
||||
featured: true,
|
||||
minStayNights: 2,
|
||||
checkInTime: '16:00',
|
||||
checkOutTime: '10:00',
|
||||
marketingTags: ['Sea views', 'Family-friendly', 'Short breaks'],
|
||||
images: [
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
|
||||
altText: 'Sunlit sitting room with a wide sea-facing window and light wood furniture.',
|
||||
primaryImage: true,
|
||||
},
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1200&q=80',
|
||||
altText: 'Terrace seating overlooking the coastline in late afternoon light.',
|
||||
},
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=900&q=80',
|
||||
altText: 'Bedroom with neutral linen and coastal tones.',
|
||||
},
|
||||
],
|
||||
amenities: ['Sea view terrace', 'Fast Wi-Fi', 'Family dining kitchen', 'Parking for one car', 'Walk-in shower'],
|
||||
pricingRules: [
|
||||
{
|
||||
label: 'Standard season',
|
||||
basePriceCents: 18500,
|
||||
weekendPriceCents: 21500,
|
||||
guestDeltaCents: 1800,
|
||||
},
|
||||
{
|
||||
label: 'Summer high season',
|
||||
basePriceCents: 22500,
|
||||
weekendPriceCents: 25500,
|
||||
guestDeltaCents: 1800,
|
||||
validFrom: '2026-06-01',
|
||||
validTo: '2026-09-30',
|
||||
},
|
||||
],
|
||||
availabilityBlocks: [
|
||||
{
|
||||
startDate: '2026-03-15',
|
||||
endDate: '2026-03-18',
|
||||
reason: 'MAINTENANCE',
|
||||
notes: 'Spring maintenance window',
|
||||
},
|
||||
{
|
||||
startDate: '2026-08-18',
|
||||
endDate: '2026-08-25',
|
||||
reason: 'OWNER_BLOCKED',
|
||||
notes: 'Owner stay',
|
||||
},
|
||||
],
|
||||
testimonials: [
|
||||
{
|
||||
authorName: 'Sophie M.',
|
||||
content: 'We could tell exactly what the stay would feel like before we booked, and the sea view more than held up.',
|
||||
rating: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'orchard-barn',
|
||||
title: 'Orchard Barn',
|
||||
area: 'Rural retreat',
|
||||
summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.',
|
||||
longDescription:
|
||||
'Orchard Barn gives groups more room to spread out without losing the warmth of a rural stay. The main space stays open and sociable, while the bedrooms and garden edges keep it comfortable for longer weekends and family trips.',
|
||||
locationText:
|
||||
'Set back from a quiet lane beside orchards and footpaths, the barn is well placed for walking routes, local pubs, and slower countryside stays.',
|
||||
sleeps: 6,
|
||||
bedrooms: 3,
|
||||
bathrooms: 2,
|
||||
petsAllowed: true,
|
||||
featured: true,
|
||||
minStayNights: 3,
|
||||
checkInTime: '15:00',
|
||||
checkOutTime: '10:30',
|
||||
marketingTags: ['Pets considered', 'Hot tub', 'Long weekends'],
|
||||
images: [
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1500&q=80',
|
||||
altText: 'Converted barn living area with high ceilings and exposed timber beams.',
|
||||
primaryImage: true,
|
||||
},
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1100&q=80',
|
||||
altText: 'Outdoor dining and garden edge beside the orchard.',
|
||||
},
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=950&q=80',
|
||||
altText: 'Bedroom with vaulted ceiling and soft rural palette.',
|
||||
},
|
||||
],
|
||||
amenities: ['Hot tub', 'Dog-friendly utility area', 'Large dining table', 'Wood burner', 'Private garden'],
|
||||
pricingRules: [
|
||||
{
|
||||
label: 'Standard season',
|
||||
basePriceCents: 21000,
|
||||
weekendPriceCents: 24000,
|
||||
guestDeltaCents: 1200,
|
||||
},
|
||||
{
|
||||
label: 'Harvest season',
|
||||
basePriceCents: 23000,
|
||||
weekendPriceCents: 26000,
|
||||
guestDeltaCents: 1200,
|
||||
validFrom: '2026-09-01',
|
||||
validTo: '2026-10-31',
|
||||
},
|
||||
],
|
||||
availabilityBlocks: [
|
||||
{
|
||||
startDate: '2026-05-12',
|
||||
endDate: '2026-05-17',
|
||||
reason: 'MAINTENANCE',
|
||||
notes: 'Hot tub service and garden works',
|
||||
},
|
||||
],
|
||||
testimonials: [
|
||||
{
|
||||
authorName: 'Daniel K.',
|
||||
content: 'The layout made group planning easy, and the practical details answered the usual pre-booking questions.',
|
||||
rating: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'harbour-house',
|
||||
title: 'Harbour House',
|
||||
area: 'Harbour front',
|
||||
summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.',
|
||||
longDescription:
|
||||
'Harbour House is a walkable base for guests who want the town on the doorstep. It works well for couples who want extra room or smaller groups who care more about location and simple access than seclusion.',
|
||||
locationText:
|
||||
'The house fronts the harbour road with quick access to cafés, restaurants, boat trips, and an easy evening walk along the water.',
|
||||
sleeps: 5,
|
||||
bedrooms: 3,
|
||||
bathrooms: 1,
|
||||
petsAllowed: false,
|
||||
featured: true,
|
||||
minStayNights: 3,
|
||||
checkInTime: '16:00',
|
||||
checkOutTime: '10:00',
|
||||
marketingTags: ['Walkable', 'Town stay', 'Flexible dates'],
|
||||
images: [
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1450&q=80',
|
||||
altText: 'Townhouse sitting room near the harbour with layered textures and soft light.',
|
||||
primaryImage: true,
|
||||
},
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1080&q=80',
|
||||
altText: 'Dining space with views toward the harbour street.',
|
||||
},
|
||||
{
|
||||
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=920&q=80',
|
||||
altText: 'Bedroom styled for a short waterside stay.',
|
||||
},
|
||||
],
|
||||
amenities: ['Harbour access', 'Walkable restaurants', 'Fast broadband', 'Compact workspace', 'Flexible sleeping setup'],
|
||||
pricingRules: [
|
||||
{
|
||||
label: 'Standard season',
|
||||
basePriceCents: 16500,
|
||||
weekendPriceCents: 19000,
|
||||
guestDeltaCents: 1500,
|
||||
},
|
||||
{
|
||||
label: 'Peak summer',
|
||||
basePriceCents: 19500,
|
||||
weekendPriceCents: 22500,
|
||||
guestDeltaCents: 1500,
|
||||
validFrom: '2026-07-01',
|
||||
validTo: '2026-08-31',
|
||||
},
|
||||
],
|
||||
availabilityBlocks: [
|
||||
{
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-06-05',
|
||||
reason: 'OWNER_BLOCKED',
|
||||
notes: 'Harbour festival owner use',
|
||||
},
|
||||
],
|
||||
testimonials: [
|
||||
{
|
||||
authorName: 'Priya R.',
|
||||
content: 'Perfect for a car-light stay. We could see what was included, where it was, and how to get started without any dead ends.',
|
||||
rating: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { propertySeedData } from '@/lib/propertySeedData';
|
||||
|
||||
export type FeaturedProperty = {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -52,45 +54,26 @@ export const site = {
|
||||
|
||||
export const primaryNavigation = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/properties', label: 'Properties' },
|
||||
{ href: '/about', label: 'About' },
|
||||
{ href: '/faqs', label: 'FAQs' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
];
|
||||
|
||||
export const featuredProperties: FeaturedProperty[] = [
|
||||
{
|
||||
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,
|
||||
priceFrom: 'From £185/night',
|
||||
tags: ['Sea views', 'Family-friendly', 'Short breaks'],
|
||||
},
|
||||
{
|
||||
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,
|
||||
priceFrom: 'From £210/night',
|
||||
tags: ['Pets considered', 'Hot tub', 'Long weekends'],
|
||||
},
|
||||
{
|
||||
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,
|
||||
priceFrom: 'From £165/night',
|
||||
tags: ['Walkable', 'Town stay', 'Flexible dates'],
|
||||
},
|
||||
...propertySeedData
|
||||
.filter((property) => property.featured)
|
||||
.map((property) => ({
|
||||
slug: property.slug,
|
||||
name: property.title,
|
||||
area: property.area,
|
||||
summary: property.summary,
|
||||
sleeps: property.sleeps,
|
||||
bedrooms: property.bedrooms,
|
||||
bathrooms: property.bathrooms,
|
||||
priceFrom: `From £${Math.round(property.pricingRules[0]?.basePriceCents ? property.pricingRules[0].basePriceCents / 100 : 0)}/night`,
|
||||
tags: property.marketingTags,
|
||||
})),
|
||||
];
|
||||
|
||||
export const locationHighlights = [
|
||||
|
||||
@@ -6,8 +6,9 @@ test.describe('homepage', () => {
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Explore featured stays' })).toHaveAttribute('href', '#browse');
|
||||
await expect(page.getByRole('link', { name: 'Browse all properties' })).toHaveAttribute('href', '/properties');
|
||||
await expect(page.getByRole('link', { name: 'Contact the team' })).toHaveAttribute('href', '/contact');
|
||||
await expect(page.getByRole('link', { name: 'Properties' })).toHaveAttribute('href', '/properties');
|
||||
});
|
||||
|
||||
test('shows the public content sections', async ({ page }) => {
|
||||
|
||||
34
tests/e2e/properties.spec.ts
Normal file
34
tests/e2e/properties.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('property directory', () => {
|
||||
test('shows the real-data listing page from navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('link', { name: 'Properties' }).click();
|
||||
|
||||
await expect(page).toHaveURL('/properties');
|
||||
await expect(page.getByRole('heading', { name: 'Search live stay rules before the booking form starts' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Coastal View Cottage' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Start booking' }).first()).toHaveAttribute(
|
||||
'href',
|
||||
'/bookings/new?propertySlug=coastal-view-cottage',
|
||||
);
|
||||
});
|
||||
|
||||
test('filters properties by dates and pet-friendly toggle', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/properties?arrivalDate=2026-07-10&departureDate=2026-07-14&adults=2&children=0&pets=1&petsAllowed=true',
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Orchard Barn' })).toBeVisible();
|
||||
await expect(page.getByText('Available for these dates')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Coastal View Cottage' })).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: 'Harbour House' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('shows an empty state when filters exclude every property', async ({ page }) => {
|
||||
await page.goto('/properties?location=harbour&bedrooms=4');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'No properties matched this combination' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
36
tests/e2e/property-detail.spec.ts
Normal file
36
tests/e2e/property-detail.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('property detail flow', () => {
|
||||
test('opens a property detail page and carries the guest into booking and enquiry entry points', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('link', { name: 'View property details' }).first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/properties\/coastal-view-cottage$/);
|
||||
await expect(page.getByRole('heading', { name: 'Coastal View Cottage' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Everything a guest needs before starting the booking flow' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Check availability' })).toHaveAttribute(
|
||||
'href',
|
||||
'/bookings/new?propertySlug=coastal-view-cottage',
|
||||
);
|
||||
await expect(page.getByRole('link', { name: 'Ask a question first' })).toHaveAttribute(
|
||||
'href',
|
||||
'/contact?property=Coastal%20View%20Cottage',
|
||||
);
|
||||
|
||||
await page.getByRole('link', { name: 'Check availability' }).click();
|
||||
await expect(page).toHaveURL(/\/bookings\/new\?propertySlug=coastal-view-cottage$/);
|
||||
await expect(page.locator('select[name="propertySlug"]')).toHaveValue('coastal-view-cottage');
|
||||
|
||||
await page.goto('/properties/coastal-view-cottage');
|
||||
await page.getByRole('link', { name: 'Ask a question first' }).click();
|
||||
await expect(page).toHaveURL(/\/contact\?property=Coastal%20View%20Cottage$/);
|
||||
await expect(page.locator('input[name="property"]')).toHaveValue('Coastal View Cottage');
|
||||
});
|
||||
|
||||
test('returns a 404 for an unknown property slug', async ({ page }) => {
|
||||
const response = await page.goto('/properties/not-a-real-property');
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user