Implement property detail and enquiry entry flow
This commit is contained in:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user