From bb80906d19ab868c5fab503239d19f3125c34ba2 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Tue, 26 May 2026 11:59:22 +0000 Subject: [PATCH] Implement property directory search --- src/app/layout.tsx | 78 +++++++- src/app/page.tsx | 63 +++--- src/app/properties/[slug]/page.tsx | 60 +----- src/app/properties/page.tsx | 303 +++++++++++++++++++++++++++++ src/lib/properties.ts | 165 ++++++++++++++++ src/lib/site.ts | 1 + tests/e2e/home.spec.ts | 3 +- tests/e2e/properties.spec.ts | 34 ++++ 8 files changed, 621 insertions(+), 86 deletions(-) create mode 100644 src/app/properties/page.tsx create mode 100644 tests/e2e/properties.spec.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 93e3ba7..534ddb5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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; @@ -750,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; @@ -798,7 +869,8 @@ main { .property-grid, .content-grid, .testimonial-grid, - .page-layout { + .page-layout, + .search-grid { grid-template-columns: 1fr; } @@ -839,6 +911,10 @@ main { .property-metrics { grid-template-columns: 1fr; } + + .search-field-wide { + grid-column: auto; + } } `; diff --git a/src/app/page.tsx b/src/app/page.tsx index 46f8f5c..4dfa969 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <>
@@ -39,8 +34,8 @@ export default function HomePage() {

{site.description}

- - Explore featured stays + + Browse all properties Contact the team @@ -59,20 +54,30 @@ export default function HomePage() {

Search preview

Plan the right stay

- 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.

-
- {bookingFields.map((field) => ( - - ))} - - Check availability - + + + + + +
@@ -126,14 +131,17 @@ export default function HomePage() {
{featuredProperties.map((property) => (
+ {property.image ? {property.image.altText} : null}

{property.area}

- {property.name} + {property.title}

- {property.priceFrom} + + {property.priceFromCents ? `From ${formatPoundsFromCents(property.priceFromCents)}/night` : 'Price on request'} +

{property.summary}

@@ -151,7 +159,7 @@ export default function HomePage() {
    - {property.tags.map((tag) => ( + {property.marketingTags.map((tag) => (
  • {tag}
  • ))}
@@ -161,6 +169,11 @@ export default function HomePage() { ))}
+
+ + Open the full property directory + +
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 { const { slug } = await params; diff --git a/src/app/properties/page.tsx b/src/app/properties/page.tsx new file mode 100644 index 0000000..270c187 --- /dev/null +++ b/src/app/properties/page.tsx @@ -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>; +}; + +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 ( + <> +
+
+

Property directory

+

Search live stay rules before the booking form starts

+

+ 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. +

+
+ +
+

What this page covers

+ {results.length} properties in view +

+ Available stays rise to the top when dates are present, and every card keeps the enquiry and booking routes + visible. +

+
+
+
Filters
+
Dates, guests, pets, bedrooms, amenities
+
+
+
Sorting
+
Availability first, then lowest total
+
+
+
+
+ +
+
+
+
+ + + + + + + + + +
+
+ + + Clear filters + +
+
+
+ +
+ {activeFilters.length ? ( +
    + {activeFilters.map((filter) => ( +
  • {filter}
  • + ))} +
+ ) : null} + +
+ {results.map((result) => { + const property = directoryBySlug.get(result.propertySlug); + if (!property) return null; + + return ( +
+ {property.image ? {property.image.altText} : null} +
+
+

{property.area}

+

+ {result.propertyName} +

+
+ + {result.totalCents > 0 + ? formatPoundsFromCents(result.totalCents) + : property.priceFromCents + ? `From ${formatPoundsFromCents(property.priceFromCents)}/night` + : 'Price on request'} + +
+ +
+ {hasDateSearch + ? result.available + ? 'Available for these dates' + : 'Check result details' + : 'Browse property overview'} +
+ +

{property.summary}

+
+
+
Sleeps
+
{property.sleeps}
+
+
+
Bedrooms
+
{property.bedrooms}
+
+
+
Bathrooms
+
{property.bathrooms}
+
+
+ +
    + {property.marketingTags.map((tag) => ( +
  • {tag}
  • + ))} +
+ + {hasDateSearch && result.reasons.length ? ( +
    + {result.reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+ ) : hasDateSearch ? ( +
+
+ {result.nights} nights + + {result.arrivalDate} to {result.departureDate} + +
+
+ ) : ( +
+
+ {property.minStayNights} night minimum stay + {property.locationText} +
+
+ )} + +
+ + Start booking + + + View details + +
+
+ ); + })} +
+ + {results.length === 0 ? ( +
+

No properties matched this combination

+

+ 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. +

+
+ ) : null} +
+
+ + ); +} diff --git a/src/lib/properties.ts b/src/lib/properties.ts index 2009820..f9ae4c3 100644 --- a/src/lib/properties.ts +++ b/src/lib/properties.ts @@ -27,6 +27,81 @@ 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() @@ -177,3 +252,93 @@ 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, + }; + }); +} diff --git a/src/lib/site.ts b/src/lib/site.ts index 83e798e..bfc6b68 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -54,6 +54,7 @@ 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' }, diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts index d886210..21ba11b 100644 --- a/tests/e2e/home.spec.ts +++ b/tests/e2e/home.spec.ts @@ -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 }) => { diff --git a/tests/e2e/properties.spec.ts b/tests/e2e/properties.spec.ts new file mode 100644 index 0000000..696eff7 --- /dev/null +++ b/tests/e2e/properties.spec.ts @@ -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(); + }); +});