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