1 Commits

Author SHA1 Message Date
bb80906d19 Implement property directory search 2026-05-26 11:59:22 +00:00
11 changed files with 622 additions and 89 deletions

View File

@@ -9,7 +9,6 @@ The deployment model is expected to follow the shared dev, QA, and production br
- `NEXT_PUBLIC_SITE_URL` should match the deployed environment. - `NEXT_PUBLIC_SITE_URL` should match the deployed environment.
- `DATABASE_URL` should target the environment-specific PostgreSQL instance. - `DATABASE_URL` should target the environment-specific PostgreSQL instance.
- Stripe and email provider secrets live in environment variables. - Stripe and email provider secrets live in environment variables.
- The production container applies Prisma migrations and seeds the property inventory before the Next.js server starts so checkout has the required `Property` records.
## Port Mapping ## Port Mapping

View File

@@ -17,4 +17,4 @@ RUN npm run build
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "-c", "npm run prisma:migrate:deploy && npm run prisma:seed && node .next/standalone/server.js"] CMD ["node", ".next/standalone/server.js"]

View File

@@ -10,7 +10,6 @@
"test": "vitest run", "test": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:dev": "prisma migrate dev",
"prisma:seed": "tsx prisma/seed.ts" "prisma:seed": "tsx prisma/seed.ts"
}, },

View File

@@ -290,6 +290,7 @@ main {
} }
.search-field input, .search-field input,
.search-field select,
.contact-form input, .contact-form input,
.contact-form textarea, .contact-form textarea,
.contact-form select { .contact-form select {
@@ -437,6 +438,16 @@ main {
background: rgba(255, 255, 255, 0.82); 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 { .property-card-top {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -750,6 +761,66 @@ main {
padding-top: 0; 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 { .contact-form {
display: grid; display: grid;
gap: 0.9rem; gap: 0.9rem;
@@ -798,7 +869,8 @@ main {
.property-grid, .property-grid,
.content-grid, .content-grid,
.testimonial-grid, .testimonial-grid,
.page-layout { .page-layout,
.search-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -839,6 +911,10 @@ main {
.property-metrics { .property-metrics {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.search-field-wide {
grid-column: auto;
}
} }
`; `;

View File

@@ -1,8 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { Section } from '@/components/Section'; import { Section } from '@/components/Section';
import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking'; import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking';
import { getFallbackPropertyDirectory } from '@/lib/properties';
import { import {
featuredProperties,
locationHighlights, locationHighlights,
site, site,
testimonials, testimonials,
@@ -14,13 +14,6 @@ const ctaPoints = [
'A direct contact route for questions before booking', '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]!, { const demoQuote = quoteStay(bookingCatalog[0]!, {
arrivalDate: '2026-07-10', arrivalDate: '2026-07-10',
departureDate: '2026-07-14', departureDate: '2026-07-14',
@@ -29,7 +22,9 @@ const demoQuote = quoteStay(bookingCatalog[0]!, {
pets: 0, pets: 0,
}); });
export default function HomePage() { export default async function HomePage() {
const featuredProperties = getFallbackPropertyDirectory().filter((property) => property.featured);
return ( return (
<> <>
<section className="hero" id="top"> <section className="hero" id="top">
@@ -39,8 +34,8 @@ export default function HomePage() {
<p>{site.description}</p> <p>{site.description}</p>
<div className="hero-actions"> <div className="hero-actions">
<Link className="btn btn-primary" href="#browse"> <Link className="btn btn-primary" href="/properties">
Explore featured stays Browse all properties
</Link> </Link>
<Link className="btn btn-outline-dark" href="/contact"> <Link className="btn btn-outline-dark" href="/contact">
Contact the team Contact the team
@@ -59,20 +54,30 @@ export default function HomePage() {
<p className="footer-label">Search preview</p> <p className="footer-label">Search preview</p>
<strong>Plan the right stay</strong> <strong>Plan the right stay</strong>
<p className="mb-0 text-body-secondary"> <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> </p>
</div> </div>
<form className="search-panel" aria-label="Availability search preview"> <form className="search-panel" aria-label="Availability search" action="/properties">
{bookingFields.map((field) => ( <label className="search-field">
<label key={field.label} className="search-field"> <span>Arrival</span>
<span>{field.label}</span> <input aria-label="Arrival" type="date" name="arrivalDate" defaultValue="2026-07-10" />
<input aria-label={field.label} defaultValue={field.value} /> </label>
</label> <label className="search-field">
))} <span>Departure</span>
<Link className="btn btn-dark" href="/bookings/new"> <input aria-label="Departure" type="date" name="departureDate" defaultValue="2026-07-14" />
Check availability </label>
</Link> <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> </form>
<div className="quote-panel" aria-label="Booking quote preview"> <div className="quote-panel" aria-label="Booking quote preview">
@@ -126,14 +131,17 @@ export default function HomePage() {
<div className="property-grid"> <div className="property-grid">
{featuredProperties.map((property) => ( {featuredProperties.map((property) => (
<article key={property.slug} className="property-card"> <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 className="property-card-top">
<div> <div>
<p className="footer-label">{property.area}</p> <p className="footer-label">{property.area}</p>
<h3> <h3>
<Link href={`/properties/${property.slug}`}>{property.name}</Link> <Link href={`/properties/${property.slug}`}>{property.title}</Link>
</h3> </h3>
</div> </div>
<span className="property-price">{property.priceFrom}</span> <span className="property-price">
{property.priceFromCents ? `From ${formatPoundsFromCents(property.priceFromCents)}/night` : 'Price on request'}
</span>
</div> </div>
<p>{property.summary}</p> <p>{property.summary}</p>
<dl className="property-metrics"> <dl className="property-metrics">
@@ -151,7 +159,7 @@ export default function HomePage() {
</div> </div>
</dl> </dl>
<ul className="tag-list"> <ul className="tag-list">
{property.tags.map((tag) => ( {property.marketingTags.map((tag) => (
<li key={tag}>{tag}</li> <li key={tag}>{tag}</li>
))} ))}
</ul> </ul>
@@ -161,6 +169,11 @@ export default function HomePage() {
</article> </article>
))} ))}
</div> </div>
<div className="section-actions">
<Link className="btn btn-outline-dark" href="/properties">
Open the full property directory
</Link>
</div>
</Section> </Section>
<Section <Section

View File

@@ -2,8 +2,7 @@ import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Section } from '@/components/Section'; import { Section } from '@/components/Section';
import { getPublishedPropertyBySlug } from '@/lib/properties'; import { buildFallbackProperty, getPublishedPropertyBySlug } from '@/lib/properties';
import { propertySeedData } from '@/lib/propertySeedData';
import { site } from '@/lib/site'; import { site } from '@/lib/site';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -49,63 +48,6 @@ function fallbackMetadata(slug: string) {
}; };
} }
function buildFallbackProperty(slug: string) {
const seeded = propertySeedData.find((property) => property.slug === slug);
if (!seeded) return null;
return {
id: seeded.slug,
slug: seeded.slug,
title: seeded.title,
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,
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 async function generateMetadata({ params }: PropertyPageProps): Promise<Metadata> { export async function generateMetadata({ params }: PropertyPageProps): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;

303
src/app/properties/page.tsx Normal file
View 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>
</>
);
}

View File

@@ -27,6 +27,81 @@ export type PropertyDetailRecord = Prisma.PropertyGetPayload<{
include: typeof propertyInclude; 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) { function slugifyAmenity(name: string) {
return name return name
.toLowerCase() .toLowerCase()
@@ -177,3 +252,93 @@ export async function getPublishedPropertyBySlug(slug: string) {
include: propertyInclude, 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,
};
});
}

View File

@@ -54,6 +54,7 @@ export const site = {
export const primaryNavigation = [ export const primaryNavigation = [
{ href: '/', label: 'Home' }, { href: '/', label: 'Home' },
{ href: '/properties', label: 'Properties' },
{ href: '/about', label: 'About' }, { href: '/about', label: 'About' },
{ href: '/faqs', label: 'FAQs' }, { href: '/faqs', label: 'FAQs' },
{ href: '/contact', label: 'Contact' }, { href: '/contact', label: 'Contact' },

View File

@@ -6,8 +6,9 @@ test.describe('homepage', () => {
await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Primary' })).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: 'Contact the team' })).toHaveAttribute('href', '/contact');
await expect(page.getByRole('link', { name: 'Properties' })).toHaveAttribute('href', '/properties');
}); });
test('shows the public content sections', async ({ page }) => { test('shows the public content sections', async ({ page }) => {

View 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();
});
});