1 Commits

Author SHA1 Message Date
38b18b7881 fix: bootstrap prisma data before app start 2026-05-31 00:56:12 +00:00
11 changed files with 89 additions and 622 deletions

View File

@@ -9,6 +9,7 @@ The deployment model is expected to follow the shared dev, QA, and production br
- `NEXT_PUBLIC_SITE_URL` should match the deployed environment.
- `DATABASE_URL` should target the environment-specific PostgreSQL instance.
- 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

View File

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

View File

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

View File

@@ -290,7 +290,6 @@ main {
}
.search-field input,
.search-field select,
.contact-form input,
.contact-form textarea,
.contact-form select {
@@ -438,16 +437,6 @@ 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;
@@ -761,66 +750,6 @@ 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;
@@ -869,8 +798,7 @@ main {
.property-grid,
.content-grid,
.testimonial-grid,
.page-layout,
.search-grid {
.page-layout {
grid-template-columns: 1fr;
}
@@ -911,10 +839,6 @@ main {
.property-metrics {
grid-template-columns: 1fr;
}
.search-field-wide {
grid-column: auto;
}
}
`;

View File

@@ -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,6 +14,13 @@ 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',
@@ -22,9 +29,7 @@ const demoQuote = quoteStay(bookingCatalog[0]!, {
pets: 0,
});
export default async function HomePage() {
const featuredProperties = getFallbackPropertyDirectory().filter((property) => property.featured);
export default function HomePage() {
return (
<>
<section className="hero" id="top">
@@ -34,8 +39,8 @@ export default async function HomePage() {
<p>{site.description}</p>
<div className="hero-actions">
<Link className="btn btn-primary" href="/properties">
Browse all properties
<Link className="btn btn-primary" href="#browse">
Explore featured stays
</Link>
<Link className="btn btn-outline-dark" href="/contact">
Contact the team
@@ -54,30 +59,20 @@ export default async function HomePage() {
<p className="footer-label">Search preview</p>
<strong>Plan the right stay</strong>
<p className="mb-0 text-body-secondary">
Search now leads into the live property directory, with filters that reuse the same availability and pricing rules as the booking flow.
The booking flow will later use live availability and pricing. This slice keeps the public browsing entry point clear.
</p>
</div>
<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 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>
<div className="quote-panel" aria-label="Booking quote preview">
@@ -131,17 +126,14 @@ export default async 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>
<Link href={`/properties/${property.slug}`}>{property.title}</Link>
<Link href={`/properties/${property.slug}`}>{property.name}</Link>
</h3>
</div>
<span className="property-price">
{property.priceFromCents ? `From ${formatPoundsFromCents(property.priceFromCents)}/night` : 'Price on request'}
</span>
<span className="property-price">{property.priceFrom}</span>
</div>
<p>{property.summary}</p>
<dl className="property-metrics">
@@ -159,7 +151,7 @@ export default async function HomePage() {
</div>
</dl>
<ul className="tag-list">
{property.marketingTags.map((tag) => (
{property.tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
@@ -169,11 +161,6 @@ export default async function HomePage() {
</article>
))}
</div>
<div className="section-actions">
<Link className="btn btn-outline-dark" href="/properties">
Open the full property directory
</Link>
</div>
</Section>
<Section

View File

@@ -2,7 +2,8 @@ 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 { getPublishedPropertyBySlug } from '@/lib/properties';
import { propertySeedData } from '@/lib/propertySeedData';
import { site } from '@/lib/site';
export const dynamic = 'force-dynamic';
@@ -48,6 +49,63 @@ 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> {
const { slug } = await params;

View File

@@ -1,303 +0,0 @@
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,81 +27,6 @@ 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()
@@ -252,93 +177,3 @@ export async function getPublishedPropertyBySlug(slug: string) {
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,7 +54,6 @@ 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' },

View File

@@ -6,9 +6,8 @@ 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: 'Browse all properties' })).toHaveAttribute('href', '/properties');
await expect(page.getByRole('link', { name: 'Explore featured stays' })).toHaveAttribute('href', '#browse');
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 }) => {

View File

@@ -1,34 +0,0 @@
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();
});
});