Compare commits
1 Commits
feature/vi
...
feature/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| 38b18b7881 |
@@ -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.
|
- `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
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ RUN npm run build
|
|||||||
|
|
||||||
EXPOSE 3000
|
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"]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -290,7 +290,6 @@ 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 {
|
||||||
@@ -438,16 +437,6 @@ 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;
|
||||||
@@ -761,66 +750,6 @@ 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;
|
||||||
@@ -869,8 +798,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,10 +839,6 @@ main {
|
|||||||
.property-metrics {
|
.property-metrics {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-field-wide {
|
|
||||||
grid-column: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +14,13 @@ 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',
|
||||||
@@ -22,9 +29,7 @@ const demoQuote = quoteStay(bookingCatalog[0]!, {
|
|||||||
pets: 0,
|
pets: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default function HomePage() {
|
||||||
const featuredProperties = getFallbackPropertyDirectory().filter((property) => property.featured);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="hero" id="top">
|
<section className="hero" id="top">
|
||||||
@@ -34,8 +39,8 @@ export default async function HomePage() {
|
|||||||
<p>{site.description}</p>
|
<p>{site.description}</p>
|
||||||
|
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<Link className="btn btn-primary" href="/properties">
|
<Link className="btn btn-primary" href="#browse">
|
||||||
Browse all properties
|
Explore featured stays
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="btn btn-outline-dark" href="/contact">
|
<Link className="btn btn-outline-dark" href="/contact">
|
||||||
Contact the team
|
Contact the team
|
||||||
@@ -54,30 +59,20 @@ export default async 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">
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="search-panel" aria-label="Availability search" action="/properties">
|
<form className="search-panel" aria-label="Availability search preview">
|
||||||
<label className="search-field">
|
{bookingFields.map((field) => (
|
||||||
<span>Arrival</span>
|
<label key={field.label} className="search-field">
|
||||||
<input aria-label="Arrival" type="date" name="arrivalDate" defaultValue="2026-07-10" />
|
<span>{field.label}</span>
|
||||||
</label>
|
<input aria-label={field.label} defaultValue={field.value} />
|
||||||
<label className="search-field">
|
</label>
|
||||||
<span>Departure</span>
|
))}
|
||||||
<input aria-label="Departure" type="date" name="departureDate" defaultValue="2026-07-14" />
|
<Link className="btn btn-dark" href="/bookings/new">
|
||||||
</label>
|
Check availability
|
||||||
<label className="search-field">
|
</Link>
|
||||||
<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">
|
||||||
@@ -131,17 +126,14 @@ export default async 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.title}</Link>
|
<Link href={`/properties/${property.slug}`}>{property.name}</Link>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="property-price">
|
<span className="property-price">{property.priceFrom}</span>
|
||||||
{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">
|
||||||
@@ -159,7 +151,7 @@ export default async function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<ul className="tag-list">
|
<ul className="tag-list">
|
||||||
{property.marketingTags.map((tag) => (
|
{property.tags.map((tag) => (
|
||||||
<li key={tag}>{tag}</li>
|
<li key={tag}>{tag}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -169,11 +161,6 @@ export default async 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
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ 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 { buildFallbackProperty, getPublishedPropertyBySlug } from '@/lib/properties';
|
import { 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';
|
||||||
@@ -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> {
|
export async function generateMetadata({ params }: PropertyPageProps): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,81 +27,6 @@ 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()
|
||||||
@@ -252,93 +177,3 @@ 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ 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' },
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ 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: '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: '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 }) => {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user