13 Commits

Author SHA1 Message Date
d1314f2181 Add booking checkout and webhook coverage
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 4m54s
Playwright Holiday Property Booking / playwright (push) Failing after 2m32s
Test & Build Holiday Property Booking / test-build (push) Successful in 1m44s
2026-05-26 09:08:43 +00:00
0aaba14300 test: align homepage spec with current content
All checks were successful
Deploy Holiday Property Booking / deploy (push) Successful in 1m43s
Playwright Holiday Property Booking / playwright (push) Successful in 1m26s
Test & Build Holiday Property Booking / test-build (push) Successful in 1m15s
2026-05-26 08:19:53 +00:00
e468876d59 ci: skip node setup when already present
Some checks failed
Deploy Holiday Property Booking / deploy (push) Successful in 1m43s
Playwright Holiday Property Booking / playwright (push) Failing after 1m42s
Test & Build Holiday Property Booking / test-build (push) Successful in 1m20s
2026-05-26 08:13:05 +00:00
b28426594c fix: make Playwright runtime work in docker
Some checks failed
Deploy Holiday Property Booking / deploy (push) Successful in 4m19s
Test & Build Holiday Property Booking / test-build (push) Has been cancelled
Playwright Holiday Property Booking / playwright (push) Has been cancelled
2026-05-26 08:03:14 +00:00
7b6d2d8603 fix: generate prisma client during docker build
Some checks failed
Playwright Holiday Property Booking / playwright (push) Failing after 6m26s
Deploy Holiday Property Booking / deploy (push) Successful in 3m13s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m42s
2026-05-25 13:38:15 +00:00
7b9ae307a5 docs: add operating cadence and clean styling
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 30s
Playwright Holiday Property Booking / playwright (push) Failing after 6m57s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m43s
2026-05-25 13:16:57 +00:00
ea7ae9087e Merge branch 'feature/vik-114-payments-checkout' into develop
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 54s
Playwright Holiday Property Booking / playwright (push) Failing after 6m59s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m50s
2026-05-24 23:56:00 +00:00
090741e6ad Add booking checkout and webhook flow 2026-05-24 23:55:43 +00:00
dbdca5c023 Merge branch 'feature/vik-115-admin-console' into develop 2026-05-24 23:54:13 +00:00
ef5e36b63d Add admin console planning surface 2026-05-24 23:53:59 +00:00
42c4482341 Merge branch 'feature/vik-113-booking-core' into develop
Some checks failed
Deploy Holiday Property Booking / deploy (push) Successful in 1m37s
Playwright Holiday Property Booking / playwright (push) Failing after 17m26s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m41s
2026-05-24 23:20:53 +00:00
6118b9fd91 Implement booking availability and pricing core 2026-05-24 23:20:31 +00:00
014307f2ec Merge branch 'feature/vik-112-public-home-contact-content' into develop
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 2h43m39s
Playwright Holiday Property Booking / playwright (push) Failing after 7m2s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m44s
2026-05-22 15:15:06 +00:00
32 changed files with 5136 additions and 587 deletions

View File

@@ -41,7 +41,27 @@ jobs:
;;
esac
- name: Check existing Node.js
id: node-check
shell: bash
run: |
set -Eeuo pipefail
if command -v node >/dev/null 2>&1; then
node_version="$(node -p 'process.versions.node')"
node_major="${node_version%%.*}"
if [ "$node_major" = "20" ]; then
echo "use_existing_node=true" >> "$GITHUB_OUTPUT"
echo "Node.js $node_version already available; skipping setup-node"
exit 0
fi
fi
echo "use_existing_node=false" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.node-check.outputs.use_existing_node != 'true'
uses: actions/setup-node@v4
with:
node-version: 20
@@ -72,4 +92,3 @@ jobs:
- name: Run Playwright suite
run: npm run test:e2e

View File

@@ -18,7 +18,27 @@ jobs:
with:
github-server-url: https://git.dumas.ddns.net
- name: Check existing Node.js
id: node-check
shell: bash
run: |
set -Eeuo pipefail
if command -v node >/dev/null 2>&1; then
node_version="$(node -p 'process.versions.node')"
node_major="${node_version%%.*}"
if [ "$node_major" = "20" ]; then
echo "use_existing_node=true" >> "$GITHUB_OUTPUT"
echo "Node.js $node_version already available; skipping setup-node"
exit 0
fi
fi
echo "use_existing_node=false" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.node-check.outputs.use_existing_node != 'true'
uses: actions/setup-node@v4
with:
node-version: 20
@@ -32,4 +52,3 @@ jobs:
- name: Build
run: npm run build

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@ prisma/dev.db
# Misc
.cache
tsconfig.tsbuildinfo
.openclaw/
.trash/

View File

@@ -0,0 +1,65 @@
# 09. Operating Cadence and Batch Plan
## Purpose
Keep the holiday-property-booking board moving in a predictable rhythm so Neo always has clear implementation work and the project does not stall between batches.
## Cadence
### Every hour: Neo takes the top Ready ticket
- Neo takes the top ticket in `Ready for Dev`.
- Neo follows the normal dev procedure for that ticket:
- move it to `In Dev`
- branch from `develop`
- implement the work
- push the branch
- report blockers or validation-ready evidence
- merge the feature branch back to `develop` when the implementation slice is done
- leave the merge-complete comment and hand the ticket forward for validation or promotion according to the playbook
- When the ticket is finished, post a Discord-ready completion summary with the ticket ID, what changed, branch/merge state, and the next step or blocker.
- Neo works one ticket at a time unless Morpheus explicitly batches related work.
### Every hour: Morpheus reviews the lanes
- Review the full board for:
- stalled `In Dev` work
- validation work waiting on `Ready for Test`, `Deploying to Dev`, or `In QA`
- release work waiting on `Ready for QA Promotion`, `Deploying to QA`, `QA Deployed`, `Ready for Production`, or `Included in Next Release`
- blockers that need triage
- queue depth in `Ready for Dev`
- If a ticket is clearly stalled, route it to the correct owner and keep the handoff explicit.
- Morpheus keeps the promotion side of the flow: wait for Neo's merge to `develop`, check the develop build, then promote `develop -> qa` when ready.
## Batch Rule
- Count the tickets that have not yet been worked on, meaning the tickets still waiting in `Backlog` or `Ready for Dev`.
- If that count is less than 5, continue the project by creating the next batch of work.
- The next batch should come from the next unresolved phase in the project plan, in dependency order.
- Keep the batch grouped so the work stays coherent and reviewable.
- Keep refilling the ready queue until there are at least 5 unworked tickets, or until the next phase is exhausted.
## Routing Rule
- For each new ready ticket, add a paste-ready comment that states:
- the current lane
- the target lane
- the next responsible agent
- the next concrete action
- Send Neo the actual handoff directly; the ticket comment is the audit trail, not the delivery channel.
- Keep the ticket comment short and actionable so it makes sense even if someone reads it later without the surrounding chat.
- Keep lane moves and comments aligned with the playbook lane model; do not skip the comment even when the move is automated or obvious.
- Do not leave the queue in a state where no ticket is clearly assigned.
## Acceptance Criteria
- Neo always has a top ready ticket to pick up on the 30-minute cadence.
- Neo always has a top ready ticket to pick up on the hourly cadence.
- Neo posts a completion summary to Discord when a ticket finishes.
- Neo still owns merge-back-to-`develop` for feature work.
- Morpheus still owns `develop -> qa` promotion after develop is green.
- Ticket moves and comments stay consistent with the playbook lane model.
- Morpheus can see the full lane state on the hourly review.
- The ready queue is replenished before it drops below 5 unworked tickets.
- New batches are created in dependency order instead of ad hoc.
- The board never stalls because the next group of tickets was not prepared.

View File

@@ -1,15 +1,20 @@
FROM node:20-alpine
FROM node:20-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
# Playwright's Chromium needs system libraries that Alpine does not provide
# reliably for this app's CI/runtime path. Install the Linux dependencies in
# the image so browser tests can launch in GitHub Actions and container runs.
RUN npx playwright install --with-deps chromium
COPY . .
RUN npm run prisma:generate
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]
CMD ["node", ".next/standalone/server.js"]

View File

@@ -33,7 +33,9 @@ Phase 1 scaffold started from the approved planning docs, and the Vikunja board
- The first build tickets are queued on the board, with later phase work staged behind them
- Post-dev flow is documented so implementation tickets always merge to `develop`, then hand off into test/validation before QA promotion
- New functionality should extend the Playwright suite so browser regression coverage grows with the app
- Operating cadence is documented so Neo takes the top `Ready for Dev` ticket every hour, posts a completion summary to Discord when done, Morpheus reviews the lanes every hour, and the queue is refilled before it drops below 5 unworked tickets
## Next Build Step
- Start with `VIK-108`, then work through `VIK-109` to `VIK-112` in order before pulling from the backlog queue.
- Once fewer than 5 tickets remain unworked, create the next batch from the next unresolved phase instead of letting the queue drain.

View File

@@ -10,6 +10,7 @@ The project has been onboarded to Vikunja and the board now uses the playbook la
The next public work is queued as tickets, with `VIK-112` staged as the next active item after the current slice.
Implementation tickets are expected to merge back to `develop` first, then hand off into `Ready for Test` / `Deploying to Dev` before Trinity validation and later QA promotion.
Any new feature work should also update the Playwright suite so the browser tests become the main regression check as coverage expands.
Operating cadence is now defined in `09-operating-cadence-and-batch-plan.md`: Neo pulls the top `Ready for Dev` ticket every hour, posts a completion summary to Discord when finished, Morpheus reviews the lanes every hour, and the ready queue is replenished when fewer than 5 tickets remain unworked.
## Working Rule
@@ -25,6 +26,7 @@ We will not build this in one shot. Each numbered document in this folder define
6. `06-admin-console.md`
7. `07-seo-accessibility-performance.md`
8. `08-implementation-plan-and-launch-readiness.md`
9. `09-operating-cadence-and-batch-plan.md`
## Source

View File

@@ -46,6 +46,7 @@ This board is normalized to the shared playbook lane model.
- Feature work now lives on this board and should be tracked as separate tickets.
- Use the playbook lane names exactly when routing work.
- When a ticket finishes `In Dev`, Neo merges the feature branch back to `develop`, leaves a merge-complete comment, and hands the ticket forward for validation rather than marking it done.
- When Morpheus routes work, send Neo the handoff directly and leave the ticket comment as a concise record of the lane change and next action; do not rely on the bucket comment as the only delivery path.
- After the merge, move the ticket into `Ready for Test` and then `Deploying to Dev` while the dev build/deploy proves the change.
- Trinity handles validation once the dev environment is ready, and the ticket only moves forward after the live check passes.
- QA promotion is a separate step after dev validation, not part of the merge itself.

View File

@@ -12,9 +12,8 @@ const compat = new FlatCompat({
const config = [
...compat.extends('next/core-web-vitals'),
{
ignores: ['.next/**', 'node_modules/**'],
ignores: ['.next/**', 'node_modules/**', '.trash/**', '.openclaw/**'],
},
];
export default config;

2279
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,9 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"start": "node .next/standalone/server.js",
"lint": "eslint .",
"test": "vitest run",
"test:e2e": "playwright test",
"prisma:generate": "prisma generate",
"prisma:migrate:dev": "prisma migrate dev",
@@ -17,18 +18,23 @@
"bootstrap": "^5.3.3",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"stripe": "^17.7.0"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "20.17.6",
"@types/react": "19.2.14",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.0.0",
"jsdom": "^25.0.1",
"prisma": "^6.10.1",
"sass": "^1.89.2",
"tsx": "^4.20.3",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^2.1.8"
}
}

199
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,199 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Section } from '@/components/Section';
import { bookingCatalog, formatPoundsFromCents } from '@/lib/booking';
import { contentPages, site, featuredProperties } from '@/lib/site';
const adminAreas = [
{
id: 'properties',
title: 'Properties',
description: 'Create, publish, and maintain the public inventory.',
actions: ['Create property', 'Edit details', 'Publish / unpublish', 'Archive property'],
},
{
id: 'pricing',
title: 'Availability and pricing',
description: 'Set rules that control quoting, seasonal pricing, and holds.',
actions: ['Add pricing rule', 'Add availability block', 'Override season rate', 'Review hold expiry'],
},
{
id: 'bookings',
title: 'Bookings and payments',
description: 'Track payment truth, booking states, and recovery steps.',
actions: ['Review booking state', 'Inspect payment record', 'Reconcile webhook event', 'Confirm notification'],
},
{
id: 'content',
title: 'Content and testimonials',
description: 'Edit the public copy without altering the booking model.',
actions: ['Edit page copy', 'Publish FAQ update', 'Manage testimonials', 'Adjust SEO metadata'],
},
{
id: 'settings',
title: 'Site settings',
description: 'Keep the business name, contact details, and booking rules aligned.',
actions: ['Update contact details', 'Adjust booking hold', 'Update defaults', 'Review guardrails'],
},
];
const managementNotes = [
'Admin access is scoped to the back office surface and is expected to require login before mutation actions are enabled.',
'Booking and payment records remain read-only truth sources until the webhook flow validates them.',
'Availability and pricing overrides should always be visible in the admin UI before they affect the public quote path.',
];
export const metadata: Metadata = {
title: `Admin console | ${site.name}`,
description: 'Admin console planning surface for properties, bookings, pricing, content, and settings.',
};
export default function AdminPage() {
return (
<>
<section className="page-hero admin-hero">
<p className="brand-kicker">Admin console</p>
<h2>Operations control room for Holiday Property Booking</h2>
<p>
This console gives the day-to-day shape for managing properties, pricing, bookings, content, and settings
without weakening the payment truth model.
</p>
<div className="admin-hero-grid">
<article className="admin-metric-card">
<p className="footer-label">Published properties</p>
<strong>{featuredProperties.length}</strong>
<span>Catalog entries available to guests</span>
</article>
<article className="admin-metric-card">
<p className="footer-label">Booking rules</p>
<strong>{bookingCatalog.reduce((sum, property) => sum + property.seasonalRates.length + property.availabilityBlocks.length, 0)}</strong>
<span>Seasonal and availability overrides captured</span>
</article>
<article className="admin-metric-card">
<p className="footer-label">Editable pages</p>
<strong>{contentPages.length}</strong>
<span>Public content routes ready for editing</span>
</article>
</div>
</section>
<div className="page-layout admin-layout">
<Section
eyebrow="Primary areas"
title="Back-office screens follow the way the business is actually run"
description="The layout is grouped around the daily operating tasks rather than a generic CMS tree."
>
<div className="admin-area-grid">
{adminAreas.map((area) => (
<article key={area.id} className="admin-card">
<p className="footer-label">{area.id}</p>
<h3>{area.title}</h3>
<p>{area.description}</p>
<ul className="admin-action-list">
{area.actions.map((action) => (
<li key={action}>{action}</li>
))}
</ul>
</article>
))}
</div>
</Section>
<aside className="content-sidebar admin-sidebar">
<article className="content-card">
<p className="footer-label">Guardrails</p>
<ul className="admin-notes">
{managementNotes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
</article>
<article className="content-card">
<h3>Quick links</h3>
<ul className="link-list">
<li>
<Link href="/">Public site</Link>
</li>
<li>
<Link href="/contact">Contact page</Link>
</li>
<li>
<Link href="/faqs">FAQs</Link>
</li>
</ul>
</article>
</aside>
</div>
<Section
eyebrow="Property management"
title="Each property keeps its own pricing, availability, and publish state"
description="The inventory is shaped to support multiple properties without special-casing a single listing."
>
<div className="admin-table">
<div className="admin-table-row admin-table-head">
<span>Property</span>
<span>Status</span>
<span>Rate from</span>
<span>Rules</span>
</div>
{bookingCatalog.map((property) => (
<div key={property.slug} className="admin-table-row">
<span>
<strong>{property.name}</strong>
<small>{property.area}</small>
</span>
<span>{property.published ? 'Published' : 'Draft'}</span>
<span>{formatPoundsFromCents(property.baseNightlyCents)}</span>
<span>
{property.seasonalRates.length} seasonal / {property.availabilityBlocks.length} availability block(s)
</span>
</div>
))}
</div>
</Section>
<Section
eyebrow="Payments and bookings"
title="Booking records stay separate from payment truth"
description="The admin surface makes it obvious where bookings are pending, paid, confirmed, or blocked."
>
<div className="admin-summary-grid">
<article className="admin-card">
<h3>Booking states</h3>
<ul className="admin-bullet-list">
<li>Pending payment before Stripe verification</li>
<li>Payment received once the webhook confirms the event</li>
<li>Confirmed after the booking is safe to display as truth</li>
<li>Cancelled or failed when payment or availability breaks</li>
</ul>
</article>
<article className="admin-card">
<h3>Webhook review</h3>
<p>
The webhook log view will be the audit point for checkout sessions, payment intents, and notification
triggers.
</p>
</article>
</div>
</Section>
<Section
eyebrow="Content"
title="Editorial pages are managed separately from booking data"
description="Content, SEO, and testimonials stay editable without coupling them to booking rules."
>
<div className="admin-content-grid">
{contentPages.map((page) => (
<article key={page.slug} className="admin-card">
<p className="footer-label">{page.slug}</p>
<h3>{page.title}</h3>
<p>{page.seoDescription}</p>
</article>
))}
</div>
</Section>
</>
);
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { searchBookings } from '@/lib/booking';
function parseNumber(value: string | null, fallback: number) {
if (value === null || value === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function GET(request: Request) {
const url = new URL(request.url);
const result = searchBookings({
arrivalDate: url.searchParams.get('arrivalDate') ?? undefined,
departureDate: url.searchParams.get('departureDate') ?? undefined,
adults: parseNumber(url.searchParams.get('adults'), 2),
children: parseNumber(url.searchParams.get('children'), 0),
pets: parseNumber(url.searchParams.get('pets'), 0),
location: url.searchParams.get('location') ?? undefined,
propertySlug: url.searchParams.get('propertySlug') ?? undefined,
});
return NextResponse.json(result);
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { simulateCompletedPayment } from '@/lib/payments';
type RouteParams = {
params: Promise<{
bookingId: string;
}>;
};
export async function POST(request: Request, { params }: RouteParams) {
const { bookingId } = await params;
await simulateCompletedPayment(bookingId);
return NextResponse.redirect(new URL(`/bookings/${bookingId}?checkout=success&source=dev`, request.url), 303);
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import { POST } from './route';
import { createBookingCheckout } from '@/lib/payments';
vi.mock('@/lib/payments', () => ({
createBookingCheckout: vi.fn(),
}));
describe('POST /api/bookings/checkout', () => {
it('returns the checkout handoff payload on success', async () => {
vi.mocked(createBookingCheckout).mockResolvedValue({
bookingId: 'booking_123',
paymentId: 'payment_123',
checkoutUrl: 'http://localhost:3000/bookings/booking_123/checkout',
checkoutMode: 'mock',
quote: {
propertySlug: 'orchard-barn',
propertyName: 'Orchard Barn',
area: 'Rural retreat',
available: true,
nights: 3,
arrivalDate: '2026-06-22',
departureDate: '2026-06-25',
holdExpiresAt: '2026-06-22T12:30:00.000Z',
reasons: [],
nightlyRates: [],
priceBreakdown: [],
totalCents: 63000,
},
});
const response = await POST(
new Request('http://localhost:3000/api/bookings/checkout', {
method: 'POST',
body: JSON.stringify({
propertySlug: 'orchard-barn',
arrivalDate: '2026-06-22',
departureDate: '2026-06-25',
adults: 2,
children: 0,
pets: 1,
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
termsAccepted: true,
}),
}),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
bookingId: 'booking_123',
paymentId: 'payment_123',
checkoutMode: 'mock',
});
expect(createBookingCheckout).toHaveBeenCalledWith(
expect.objectContaining({
propertySlug: 'orchard-barn',
adults: 2,
pets: 1,
firstName: 'Casey',
email: 'casey@example.com',
termsAccepted: true,
}),
);
});
it('returns a 400 payload when checkout creation fails', async () => {
vi.mocked(createBookingCheckout).mockRejectedValue(new Error('Unknown property'));
const response = await POST(
new Request('http://localhost:3000/api/bookings/checkout', {
method: 'POST',
body: JSON.stringify({ propertySlug: 'missing' }),
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
ok: false,
error: 'Unknown property',
});
});
});

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { createBookingCheckout } from '@/lib/payments';
export async function POST(request: Request) {
const body = (await request.json()) as Record<string, unknown>;
try {
const result = await createBookingCheckout({
propertySlug: String(body.propertySlug || ''),
arrivalDate: body.arrivalDate ? String(body.arrivalDate) : undefined,
departureDate: body.departureDate ? String(body.departureDate) : undefined,
adults: Number(body.adults ?? 2),
children: Number(body.children ?? 0),
pets: Number(body.pets ?? 0),
location: body.location ? String(body.location) : undefined,
firstName: String(body.firstName || ''),
lastName: String(body.lastName || ''),
email: String(body.email || ''),
phone: body.phone ? String(body.phone) : undefined,
specialRequests: body.specialRequests ? String(body.specialRequests) : undefined,
termsAccepted: Boolean(body.termsAccepted),
});
return NextResponse.json({ ok: true, ...result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Checkout failed';
return NextResponse.json({ ok: false, error: message }, { status: 400 });
}
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest';
import { POST } from './route';
import { handleStripeWebhookBody } from '@/lib/payments';
vi.mock('@/lib/payments', () => ({
handleStripeWebhookBody: vi.fn(),
}));
describe('POST /api/stripe/webhook', () => {
it('passes the raw request body and Stripe signature through to the webhook handler', async () => {
vi.mocked(handleStripeWebhookBody).mockResolvedValue({
bookingId: 'booking_123',
paymentId: 'payment_123',
status: 'CONFIRMED',
notification: null,
});
const response = await POST(
new Request('http://localhost:3000/api/stripe/webhook', {
method: 'POST',
headers: {
'stripe-signature': 'sig_test_123',
},
body: '{"id":"evt_123"}',
}),
);
expect(handleStripeWebhookBody).toHaveBeenCalledWith('{"id":"evt_123"}', 'sig_test_123');
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
bookingId: 'booking_123',
status: 'CONFIRMED',
});
});
it('returns a 400 payload when webhook processing fails', async () => {
vi.mocked(handleStripeWebhookBody).mockRejectedValue(new Error('Webhook payload did not include booking metadata'));
const response = await POST(
new Request('http://localhost:3000/api/stripe/webhook', {
method: 'POST',
body: '{}',
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
ok: false,
error: 'Webhook payload did not include booking metadata',
});
});
});

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { handleStripeWebhookBody } from '@/lib/payments';
export async function POST(request: Request) {
try {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
const result = await handleStripeWebhookBody(rawBody, signature);
return NextResponse.json({ ok: true, ...result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Webhook failed';
return NextResponse.json({ ok: false, error: message }, { status: 400 });
}
}

View File

@@ -0,0 +1,56 @@
import type { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import BookingCheckoutPage from './page';
import { getBookingCheckoutContext } from '@/lib/payments';
vi.mock('next/link', () => ({
default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock('next/navigation', () => ({
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));
vi.mock('@/lib/payments', () => ({
getBookingCheckoutContext: vi.fn(),
}));
describe('booking checkout page', () => {
it('renders the Stripe handoff and local fallback guidance', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue({
id: 'booking_123',
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
property: {
title: 'Orchard Barn',
},
payment: {
status: 'REQUIRES_PAYMENT',
},
} as Awaited<ReturnType<typeof getBookingCheckoutContext>>);
const markup = renderToStaticMarkup(
await BookingCheckoutPage({
params: Promise.resolve({
bookingId: 'booking_123',
}),
}),
);
expect(markup).toContain('Finish payment for Orchard Barn');
expect(markup).toContain('Stripe Checkout collects payment when keys are configured.');
expect(markup).toContain('Open booking status');
expect(markup).toContain('/bookings/booking_123');
});
});

View File

@@ -0,0 +1,108 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Section } from '@/components/Section';
import { formatPoundsFromCents } from '@/lib/booking';
import { getBookingCheckoutContext } from '@/lib/payments';
import { site } from '@/lib/site';
type CheckoutPageProps = {
params: Promise<{
bookingId: string;
}>;
};
export async function generateMetadata({ params }: { params: Promise<{ bookingId: string }> }): Promise<Metadata> {
const { bookingId } = await params;
return {
title: `Checkout ${bookingId} | ${site.name}`,
description: 'Checkout handoff page for the booking flow.',
};
}
export default async function BookingCheckoutPage({ params }: CheckoutPageProps) {
const { bookingId } = await params;
const booking = await getBookingCheckoutContext(bookingId);
if (!booking) {
notFound();
}
return (
<>
<section className="page-hero">
<p className="brand-kicker">Checkout</p>
<h2>Finish payment for {booking.property.title}</h2>
<p>
Review the quote, then continue to Stripe if the session is configured or use the local simulation path in
development.
</p>
</section>
<div className="page-layout">
<Section
eyebrow="Quote"
title="Booking summary"
description="This page is the last step before the Stripe session or the local dev fallback."
>
<div className="admin-summary-grid">
<article className="admin-card">
<p className="footer-label">Stay</p>
<h3>{booking.property.title}</h3>
<p className="mb-0">
{booking.arrivalDate.toISOString().slice(0, 10)} to {booking.departureDate.toISOString().slice(0, 10)}
</p>
</article>
<article className="admin-card">
<p className="footer-label">Total</p>
<h3>{formatPoundsFromCents(booking.totalCents)}</h3>
<p className="mb-0">Current payment state: {booking.payment?.status ?? 'REQUIRES_PAYMENT'}</p>
</article>
</div>
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>What happens next</h3>
<ul>
<li>Stripe Checkout collects payment when keys are configured.</li>
<li>The webhook finalises the payment and booking state.</li>
<li>Email notifications are composed from the payment outcome.</li>
</ul>
</article>
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>Development fallback</h3>
<p>
If Stripe is not configured in this environment, open the booking status page and use the simulation
button to trigger the same webhook finalisation path.
</p>
<Link className="btn btn-dark" href={`/bookings/${booking.id}`}>
Open booking status
</Link>
</article>
</Section>
<aside className="content-sidebar">
<article className="content-card">
<p className="footer-label">Guest</p>
<p className="mb-0">
{booking.firstName} {booking.lastName}
<br />
{booking.email}
</p>
</article>
<article className="content-card">
<h3>Navigation</h3>
<ul className="link-list">
<li>
<Link href="/">Back to home</Link>
</li>
<li>
<Link href="/admin">Open admin console</Link>
</li>
</ul>
</article>
</aside>
</div>
</>
);
}

View File

@@ -0,0 +1,89 @@
import type { ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import BookingPage from './page';
import { getBookingCheckoutContext } from '@/lib/payments';
vi.mock('next/link', () => ({
default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock('next/navigation', () => ({
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));
vi.mock('@/lib/payments', () => ({
getBookingCheckoutContext: vi.fn(),
}));
function makeBooking(paymentStatus: 'REQUIRES_PAYMENT' | 'COMPLETED' | 'FAILED', bookingStatus: 'PENDING_PAYMENT' | 'CONFIRMED' | 'FAILED') {
return {
id: 'booking_123',
status: bookingStatus,
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
phone: null,
holdExpiresAt: new Date('2026-06-22T12:30:00.000Z'),
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
property: {
title: 'Orchard Barn',
},
payment: {
status: paymentStatus,
},
} as Awaited<ReturnType<typeof getBookingCheckoutContext>>;
}
describe('booking status page', () => {
it('shows the return-state warning until the webhook confirms the booking', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue(makeBooking('REQUIRES_PAYMENT', 'PENDING_PAYMENT'));
const markup = renderToStaticMarkup(
await BookingPage({
params: Promise.resolve({ bookingId: 'booking_123' }),
searchParams: Promise.resolve({ checkout: 'success' }),
}),
);
expect(markup).toContain('Return from checkout');
expect(markup).toContain('only final once the payment record shows a completed webhook event');
expect(markup).toContain('Simulate successful payment');
});
it('shows a confirmed state after payment completion', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue(makeBooking('COMPLETED', 'CONFIRMED'));
const markup = renderToStaticMarkup(
await BookingPage({
params: Promise.resolve({ bookingId: 'booking_123' }),
searchParams: Promise.resolve({}),
}),
);
expect(markup).toContain('Payment verified');
expect(markup).not.toContain('Simulate successful payment');
});
it('shows a failed payment outcome when checkout is cancelled or payment fails', async () => {
vi.mocked(getBookingCheckoutContext).mockResolvedValue(makeBooking('FAILED', 'FAILED'));
const markup = renderToStaticMarkup(
await BookingPage({
params: Promise.resolve({ bookingId: 'booking_123' }),
searchParams: Promise.resolve({ checkout: 'cancelled' }),
}),
);
expect(markup).toContain('Payment not completed');
expect(markup).toContain('This booking remains unconfirmed');
});
});

View File

@@ -0,0 +1,136 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Section } from '@/components/Section';
import { formatPoundsFromCents } from '@/lib/booking';
import { getBookingCheckoutContext } from '@/lib/payments';
import { site } from '@/lib/site';
type BookingPageProps = {
params: Promise<{
bookingId: string;
}>;
searchParams: Promise<{
checkout?: string;
session_id?: string;
source?: string;
}>;
};
export async function generateMetadata({ params }: { params: Promise<{ bookingId: string }> }): Promise<Metadata> {
const { bookingId } = await params;
return {
title: `Booking ${bookingId} | ${site.name}`,
description: 'Booking confirmation and payment status page.',
};
}
export default async function BookingPage({ params, searchParams }: BookingPageProps) {
const { bookingId } = await params;
const query = await searchParams;
const booking = await getBookingCheckoutContext(bookingId);
if (!booking) {
notFound();
}
const paymentStatus = booking.payment?.status ?? 'REQUIRES_PAYMENT';
const paymentCompleted = paymentStatus === 'COMPLETED';
const paymentFailed = paymentStatus === 'FAILED' || booking.status === 'FAILED' || query.checkout === 'cancelled';
return (
<>
<section className="page-hero">
<p className="brand-kicker">Booking status</p>
<h2>{booking.property.title}</h2>
<p>
{booking.firstName} {booking.lastName} {booking.arrivalDate.toISOString().slice(0, 10)} to{' '}
{booking.departureDate.toISOString().slice(0, 10)}
</p>
</section>
<div className="page-layout">
<Section
eyebrow="Truth source"
title="Booking and payment state are tracked separately"
description="The browser return page is informational only. The payment record and booking record tell the real story."
>
<div className="admin-summary-grid">
<article className="admin-card">
<p className="footer-label">Booking status</p>
<h3>{booking.status}</h3>
<p className="mb-0">Hold expires at {booking.holdExpiresAt?.toISOString() ?? 'not set'}</p>
</article>
<article className="admin-card">
<p className="footer-label">Payment status</p>
<h3>{paymentStatus}</h3>
<p className="mb-0">Total {formatPoundsFromCents(booking.totalCents)}</p>
</article>
</div>
{query.checkout === 'success' ? (
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>Return from checkout</h3>
<p className="mb-0">
The checkout return says success, but the booking is only final once the payment record shows a completed webhook event.
</p>
</article>
) : null}
{paymentFailed ? (
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>Payment not completed</h3>
<p className="mb-0">
Checkout did not complete successfully. This booking remains unconfirmed until a new successful payment event is recorded.
</p>
</article>
) : null}
{!paymentCompleted ? (
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>Development fallback</h3>
<p>
If Stripe checkout is not configured in this environment, use the local simulation button to finish
the booking and trigger the notification path.
</p>
<form action={`/api/bookings/${booking.id}/simulate-success`} method="post">
<button className="btn btn-dark" type="submit">
Simulate successful payment
</button>
</form>
</article>
) : (
<article className="content-card" style={{ marginTop: '1rem' }}>
<h3>Payment verified</h3>
<p className="mb-0">
The webhook has confirmed payment and the booking is now safe to display as confirmed.
</p>
</article>
)}
</Section>
<aside className="content-sidebar">
<article className="content-card">
<p className="footer-label">Guest details</p>
<p className="mb-0">
{booking.email}
<br />
{booking.phone || 'No phone provided'}
</p>
</article>
<article className="content-card">
<h3>Actions</h3>
<ul className="link-list">
<li>
<Link href="/">Back to home</Link>
</li>
<li>
<Link href="/admin">Open admin console</Link>
</li>
</ul>
</article>
</aside>
</div>
</>
);
}

View File

@@ -0,0 +1,143 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { Section } from '@/components/Section';
import { bookingCatalog } from '@/lib/booking';
import { createBookingCheckout } from '@/lib/payments';
import { site } from '@/lib/site';
export const metadata: Metadata = {
title: `Book a stay | ${site.name}`,
description: 'Start a holiday property booking, check the live quote core, and continue to checkout.',
};
async function startBooking(formData: FormData) {
'use server';
const result = await createBookingCheckout({
propertySlug: String(formData.get('propertySlug') || ''),
arrivalDate: String(formData.get('arrivalDate') || ''),
departureDate: String(formData.get('departureDate') || ''),
adults: Number(formData.get('adults') || 2),
children: Number(formData.get('children') || 0),
pets: Number(formData.get('pets') || 0),
location: undefined,
firstName: String(formData.get('firstName') || ''),
lastName: String(formData.get('lastName') || ''),
email: String(formData.get('email') || ''),
phone: String(formData.get('phone') || ''),
specialRequests: String(formData.get('specialRequests') || ''),
termsAccepted: formData.get('termsAccepted') === 'on',
});
redirect(result.checkoutUrl);
}
export default function NewBookingPage() {
return (
<>
<section className="page-hero">
<p className="brand-kicker">Booking</p>
<h2>Check availability and start the booking flow</h2>
<p>
This form uses the shared availability and pricing core, creates a booking record before payment, and
hands off to Stripe Checkout or the local dev fallback.
</p>
</section>
<div className="page-layout">
<Section
eyebrow="Booking form"
title="Create a booking hold"
description="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}>
{bookingCatalog.map((property) => (
<option key={property.slug} value={property.slug}>
{property.name}
</option>
))}
</select>
</label>
<div className="metric-grid">
<label>
<span>Arrival</span>
<input type="date" name="arrivalDate" required />
</label>
<label>
<span>Departure</span>
<input type="date" name="departureDate" required />
</label>
</div>
<div className="metric-grid">
<label>
<span>Adults</span>
<input type="number" name="adults" min={1} defaultValue={2} />
</label>
<label>
<span>Children</span>
<input type="number" name="children" min={0} defaultValue={0} />
</label>
</div>
<div className="metric-grid">
<label>
<span>Pets</span>
<input type="number" name="pets" min={0} defaultValue={0} />
</label>
<label>
<span>Phone</span>
<input type="tel" name="phone" placeholder="Optional phone number" />
</label>
</div>
<div className="metric-grid">
<label>
<span>First name</span>
<input type="text" name="firstName" required />
</label>
<label>
<span>Last name</span>
<input type="text" name="lastName" required />
</label>
</div>
<label>
<span>Email</span>
<input type="email" name="email" required />
</label>
<label className="contact-form-message">
<span>Special requests</span>
<textarea name="specialRequests" rows={5} placeholder="Arrival notes, accessibility requests, or questions." />
</label>
<label style={{ display: 'flex', gap: '0.6rem', alignItems: 'center' }}>
<input type="checkbox" name="termsAccepted" required />
<span>I accept the terms and understand the booking hold starts before checkout.</span>
</label>
<button className="btn btn-dark" type="submit">
Continue to checkout
</button>
</form>
</Section>
<aside className="content-sidebar">
<article className="content-card">
<p className="footer-label">Flow</p>
<ul className="admin-bullet-list">
<li>Availability is checked before payment starts.</li>
<li>The booking record is created first.</li>
<li>Stripe Checkout finalises payment truth.</li>
<li>The webhook is the source of truth for confirmation.</li>
</ul>
</article>
<article className="content-card">
<h3>What happens next</h3>
<p className="mb-0">
If Stripe is configured, you will be redirected there. Otherwise the booking status page opens with a
local simulation path for development.
</p>
</article>
</aside>
</div>
</>
);
}

View File

@@ -1,547 +0,0 @@
$body-bg: #f4efe7;
$body-color: #1a1714;
$primary: #7a543d;
$secondary: #2e6661;
$success: #2f6b43;
$warning: #b07d24;
$danger: #a63d3d;
$border-radius: 1rem;
$font-family-sans-serif: "Avenir Next", "Segoe UI", sans-serif;
$headings-font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
@import "bootstrap/scss/bootstrap";
:root {
--shell-bg: #f4efe7;
--panel-bg: rgba(255, 255, 255, 0.72);
--panel-border: rgba(26, 23, 20, 0.08);
--accent: #7a543d;
--accent-2: #2e6661;
--text-muted: #63594f;
--shadow: 0 24px 80px rgba(23, 19, 14, 0.12);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, rgba(122, 84, 61, 0.18), transparent 30%),
radial-gradient(circle at 80% 10%, rgba(46, 102, 97, 0.18), transparent 24%),
var(--shell-bg);
}
a {
color: inherit;
text-decoration: none;
}
main {
position: relative;
}
.app-shell {
min-height: 100vh;
padding: 1.25rem;
}
.surface {
max-width: 1180px;
margin: 0 auto;
border: 1px solid var(--panel-border);
border-radius: 1.75rem;
background: var(--panel-bg);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.site-header,
.site-footer,
.hero,
.section-shell {
padding: 1.5rem;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--panel-border);
}
.brand-lockup {
display: flex;
align-items: center;
gap: 1rem;
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
font-weight: 700;
letter-spacing: 0.08em;
}
.brand-kicker,
.footer-label,
.section-eyebrow {
margin: 0 0 0.25rem;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
}
.brand-lockup h1 {
margin: 0;
font-size: 1.05rem;
}
.site-nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.site-nav a {
padding: 0.55rem 0.85rem;
border: 1px solid rgba(26, 23, 20, 0.12);
border-radius: 999px;
font-size: 0.92rem;
color: var(--text-muted);
transition:
transform 160ms ease,
border-color 160ms ease,
background-color 160ms ease;
}
.site-nav a:hover,
.site-nav a:focus-visible {
transform: translateY(-1px);
border-color: rgba(122, 84, 61, 0.35);
background: rgba(255, 255, 255, 0.8);
color: var(--body-color);
}
.hero {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 1.5rem;
align-items: stretch;
}
.hero-copy,
.hero-panel,
.info-card,
.phase-card,
.data-card {
border: 1px solid var(--panel-border);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.78);
}
.hero-copy {
padding: 2rem;
}
.hero-copy h2 {
margin: 0 0 1rem;
font-size: clamp(2.2rem, 4vw, 4.2rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.hero-copy p {
max-width: 60ch;
color: var(--text-muted);
font-size: 1.06rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
.hero-actions .btn {
border-radius: 999px;
padding-inline: 1.1rem;
}
.hero-points {
display: grid;
gap: 0.55rem;
margin: 1.5rem 0 0;
padding-left: 1.1rem;
color: var(--text-muted);
}
.hero-panel {
display: grid;
gap: 1rem;
padding: 1.5rem;
}
.search-panel {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 1.25rem;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(26, 23, 20, 0.08);
}
.search-field {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.92rem;
}
.search-field input,
.contact-form input,
.contact-form textarea {
width: 100%;
border: 1px solid rgba(26, 23, 20, 0.14);
border-radius: 0.9rem;
padding: 0.8rem 0.95rem;
background: rgba(255, 255, 255, 0.94);
color: var(--body-color);
}
.search-field input:focus,
.contact-form input:focus,
.contact-form textarea:focus {
outline: 2px solid rgba(122, 84, 61, 0.28);
outline-offset: 2px;
}
.info-card,
.phase-card,
.data-card,
.content-card,
.property-card,
.testimonial-card {
padding: 1rem;
}
.metric-grid,
.phase-grid,
.data-grid,
.property-grid,
.content-grid,
.testimonial-grid,
.card-stack,
.content-stack {
display: grid;
gap: 1rem;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric {
padding: 1rem;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 240, 231, 0.9));
}
.metric strong {
display: block;
margin-bottom: 0.3rem;
font-size: 1.6rem;
}
.section-heading h2 {
margin: 0;
font-size: clamp(1.5rem, 2vw, 2.1rem);
}
.section-description {
max-width: 65ch;
margin: 0.5rem 0 0;
color: var(--text-muted);
}
.phase-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.phase-card h3,
.data-card h3 {
margin-top: 0;
font-size: 1.05rem;
}
.phase-card ul,
.data-card ul {
margin: 0.75rem 0 0;
padding-left: 1.1rem;
color: var(--text-muted);
}
.data-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.property-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.property-card {
display: grid;
gap: 0.9rem;
border: 1px solid var(--panel-border);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
}
.property-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.property-card h3,
.content-card h3,
.testimonial-card strong {
margin: 0;
}
.property-price {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: rgba(46, 102, 97, 0.12);
color: var(--accent-2);
font-size: 0.85rem;
white-space: nowrap;
}
.property-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.property-metrics div {
padding: 0.75rem;
border-radius: 0.95rem;
background: rgba(244, 239, 231, 0.88);
}
.property-metrics dt {
color: var(--text-muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.property-metrics dd {
margin: 0.25rem 0 0;
font-size: 1rem;
font-weight: 700;
}
.tag-list,
.link-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.tag-list li {
padding: 0.32rem 0.62rem;
border-radius: 999px;
background: rgba(122, 84, 61, 0.12);
color: var(--accent);
font-size: 0.82rem;
}
.content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.content-grid-tight {
align-items: flex-start;
}
.content-card,
.testimonial-card {
border: 1px solid var(--panel-border);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
}
.content-card p:last-child,
.testimonial-card p:last-child {
margin-bottom: 0;
}
.inline-link {
color: var(--accent-2);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.testimonial-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.testimonial-card {
display: grid;
gap: 1rem;
}
.testimonial-card footer {
display: grid;
gap: 0.15rem;
color: var(--text-muted);
}
.cta-band,
.page-hero {
border: 1px solid var(--panel-border);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.82);
}
.cta-band {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.1rem 1.25rem;
}
.page-hero {
margin: 1.5rem;
padding: 1.5rem;
}
.page-hero h2 {
margin: 0 0 0.6rem;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 0.98;
letter-spacing: -0.04em;
}
.page-layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.75fr);
gap: 0.5rem;
}
.page-layout .section-shell {
padding-top: 0;
}
.contact-form {
display: grid;
gap: 0.9rem;
}
.contact-form label {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
}
.contact-form-message {
grid-column: 1 / -1;
}
.contact-aside {
display: grid;
gap: 1rem;
align-content: start;
padding: 1.5rem 1.5rem 1.5rem 0;
}
.content-sidebar {
display: grid;
gap: 1rem;
align-content: start;
}
.site-footer {
display: flex;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid var(--panel-border);
color: var(--text-muted);
}
.site-footer p {
margin: 0;
}
@media (max-width: 900px) {
.hero,
.phase-grid,
.data-grid,
.property-grid,
.content-grid,
.testimonial-grid,
.page-layout {
grid-template-columns: 1fr;
}
.site-header,
.site-footer,
.cta-band {
flex-direction: column;
align-items: flex-start;
}
.contact-aside {
padding: 0 1.5rem 1.5rem;
}
}
@media (max-width: 640px) {
.app-shell {
padding: 0.75rem;
}
.site-header,
.hero,
.section-shell,
.site-footer {
padding: 1rem;
}
.page-hero {
margin: 0.75rem;
padding: 1rem;
}
.metric-grid {
grid-template-columns: 1fr;
}
.property-metrics {
grid-template-columns: 1fr;
}
}

View File

@@ -1,5 +1,4 @@
import type { Metadata } from 'next';
import './globals.scss';
import { SiteFooter } from '@/components/SiteFooter';
import { SiteHeader } from '@/components/SiteHeader';
import { site } from '@/lib/site';
@@ -9,6 +8,765 @@ export const metadata: Metadata = {
description: site.description,
};
const globalStyles = String.raw`
:root {
--shell-bg: #f4efe7;
--panel-bg: rgba(255, 255, 255, 0.72);
--panel-border: rgba(26, 23, 20, 0.08);
--accent: #7a543d;
--accent-2: #2e6661;
--text-muted: #63594f;
--shadow: 0 24px 80px rgba(23, 19, 14, 0.12);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, rgba(122, 84, 61, 0.18), transparent 30%),
radial-gradient(circle at 80% 10%, rgba(46, 102, 97, 0.18), transparent 24%),
var(--shell-bg);
}
a {
color: inherit;
text-decoration: none;
}
button,
.btn {
font: inherit;
}
button {
border: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.7rem 1rem;
border: 1px solid transparent;
border-radius: 999px;
cursor: pointer;
line-height: 1.1;
transition:
transform 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
.btn:hover,
.btn:focus-visible {
transform: translateY(-1px);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover,
.btn-primary:focus-visible {
background: #6a4732;
border-color: #6a4732;
color: #fff;
}
.btn-outline-dark {
background: transparent;
border-color: rgba(26, 23, 20, 0.18);
color: #1a1714;
}
.btn-outline-dark:hover,
.btn-outline-dark:focus-visible {
background: rgba(255, 255, 255, 0.86);
border-color: rgba(26, 23, 20, 0.28);
color: #1a1714;
}
.btn-dark {
background: #1a1714;
border-color: #1a1714;
color: #fff;
}
.btn-dark:hover,
.btn-dark:focus-visible {
background: #2a241f;
border-color: #2a241f;
color: #fff;
}
.text-body-secondary,
.mb-0 {
color: var(--text-muted);
}
.mb-0 {
margin-bottom: 0 !important;
}
main {
position: relative;
}
.app-shell {
min-height: 100vh;
padding: 1.25rem;
}
.surface {
max-width: 1180px;
margin: 0 auto;
border: 1px solid var(--panel-border);
border-radius: 1.75rem;
background: var(--panel-bg);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.site-header,
.site-footer,
.hero,
.section-shell {
padding: 1.5rem;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--panel-border);
}
.brand-lockup {
display: flex;
align-items: center;
gap: 1rem;
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
font-weight: 700;
letter-spacing: 0.08em;
}
.brand-kicker,
.footer-label,
.section-eyebrow {
margin: 0 0 0.25rem;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
}
.brand-lockup h1 {
margin: 0;
font-size: 1.05rem;
}
.site-nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.site-nav a {
padding: 0.55rem 0.85rem;
border: 1px solid rgba(26, 23, 20, 0.12);
border-radius: 999px;
font-size: 0.92rem;
color: var(--text-muted);
transition:
transform 160ms ease,
border-color 160ms ease,
background-color 160ms ease;
}
.site-nav a:hover,
.site-nav a:focus-visible {
transform: translateY(-1px);
border-color: rgba(122, 84, 61, 0.35);
background: rgba(255, 255, 255, 0.8);
color: #1a1714;
}
.hero {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 1.5rem;
align-items: stretch;
}
.hero-copy,
.hero-panel,
.info-card,
.phase-card,
.data-card {
border: 1px solid var(--panel-border);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.78);
}
.hero-copy {
padding: 2rem;
}
.hero-copy h2 {
margin: 0 0 1rem;
font-size: clamp(2.2rem, 4vw, 4.2rem);
line-height: 0.95;
letter-spacing: -0.04em;
}
.hero-copy p {
max-width: 60ch;
color: var(--text-muted);
font-size: 1.06rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
.hero-actions .btn {
border-radius: 999px;
padding-inline: 1.1rem;
}
.hero-points {
display: grid;
gap: 0.55rem;
margin: 1.5rem 0 0;
padding-left: 1.1rem;
color: var(--text-muted);
}
.hero-panel {
display: grid;
gap: 1rem;
padding: 1.5rem;
}
.search-panel {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 1.25rem;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(26, 23, 20, 0.08);
}
.search-field {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.92rem;
}
.search-field input,
.contact-form input,
.contact-form textarea,
.contact-form select {
width: 100%;
border: 1px solid rgba(26, 23, 20, 0.14);
border-radius: 0.9rem;
padding: 0.8rem 0.95rem;
background: rgba(255, 255, 255, 0.94);
color: #1a1714;
}
.search-field input:focus,
.contact-form input:focus,
.contact-form textarea:focus,
.contact-form select:focus {
outline: 2px solid rgba(122, 84, 61, 0.28);
outline-offset: 2px;
}
.quote-panel {
display: grid;
gap: 0.65rem;
padding: 1rem;
border-radius: 1.25rem;
border: 1px solid rgba(46, 102, 97, 0.18);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 248, 247, 0.92));
}
.availability-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.availability-pill.is-available {
background: rgba(46, 102, 97, 0.12);
color: var(--accent-2);
}
.availability-pill.is-unavailable {
background: rgba(122, 84, 61, 0.12);
color: var(--accent);
}
.quote-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.quote-heading h3 {
margin: 0;
}
.quote-heading strong {
font-size: 1.5rem;
white-space: nowrap;
}
.info-card,
.phase-card,
.data-card,
.content-card,
.property-card,
.testimonial-card {
padding: 1rem;
}
.metric-grid,
.phase-grid,
.data-grid,
.property-grid,
.content-grid,
.testimonial-grid,
.card-stack,
.content-stack {
display: grid;
gap: 1rem;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric {
padding: 1rem;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 240, 231, 0.9));
}
.metric strong {
display: block;
margin-bottom: 0.3rem;
font-size: 1.6rem;
}
.section-heading h2 {
margin: 0;
font-size: clamp(1.5rem, 2vw, 2.1rem);
}
.section-description {
max-width: 65ch;
margin: 0.5rem 0 0;
color: var(--text-muted);
}
.phase-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.phase-card h3,
.data-card h3 {
margin-top: 0;
font-size: 1.05rem;
}
.phase-card ul,
.data-card ul {
margin: 0.75rem 0 0;
padding-left: 1.1rem;
color: var(--text-muted);
}
.data-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.property-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.property-card {
display: grid;
gap: 0.9rem;
border: 1px solid var(--panel-border);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
}
.property-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.property-card h3,
.content-card h3,
.testimonial-card strong {
margin: 0;
}
.property-price {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: rgba(46, 102, 97, 0.12);
color: var(--accent-2);
font-size: 0.85rem;
white-space: nowrap;
}
.property-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.property-metrics div {
padding: 0.75rem;
border-radius: 0.95rem;
background: rgba(244, 239, 231, 0.88);
}
.property-metrics dt {
color: var(--text-muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.property-metrics dd {
margin: 0.25rem 0 0;
font-size: 1rem;
font-weight: 700;
}
.tag-list,
.link-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.tag-list li {
padding: 0.32rem 0.62rem;
border-radius: 999px;
background: rgba(122, 84, 61, 0.12);
color: var(--accent);
font-size: 0.82rem;
}
.content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.content-grid-tight {
align-items: flex-start;
}
.content-card,
.testimonial-card {
border: 1px solid var(--panel-border);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
}
.content-card p:last-child,
.testimonial-card p:last-child {
margin-bottom: 0;
}
.inline-link {
color: var(--accent-2);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.testimonial-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.testimonial-card {
display: grid;
gap: 1rem;
}
.testimonial-card footer {
display: grid;
gap: 0.15rem;
color: var(--text-muted);
}
.cta-band,
.page-hero {
border: 1px solid var(--panel-border);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.82);
}
.cta-band {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.1rem 1.25rem;
}
.page-hero {
margin: 1.5rem;
padding: 1.5rem;
}
.page-hero h2 {
margin: 0 0 0.6rem;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 0.98;
letter-spacing: -0.04em;
}
.admin-hero {
display: grid;
gap: 1rem;
}
.admin-hero-grid,
.admin-area-grid,
.admin-summary-grid,
.admin-content-grid {
display: grid;
gap: 1rem;
}
.admin-hero-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-layout {
align-items: start;
}
.admin-sidebar {
align-self: start;
}
.admin-metric-card,
.admin-card {
border: 1px solid var(--panel-border);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
padding: 1rem;
}
.admin-metric-card strong {
display: block;
margin-bottom: 0.3rem;
font-size: 2rem;
}
.admin-metric-card span {
color: var(--text-muted);
}
.admin-area-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-action-list,
.admin-bullet-list,
.admin-notes {
margin: 0.75rem 0 0;
padding-left: 1.1rem;
color: var(--text-muted);
}
.admin-table {
display: grid;
gap: 0.6rem;
}
.admin-table-row {
display: grid;
grid-template-columns: 1.8fr 0.8fr 0.7fr 1.2fr;
gap: 0.8rem;
align-items: start;
padding: 0.85rem 1rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.82);
border: 1px solid var(--panel-border);
}
.admin-table-head {
background: rgba(46, 102, 97, 0.08);
font-weight: 700;
}
.admin-table-row strong {
display: block;
}
.admin-table-row small {
display: block;
color: var(--text-muted);
margin-top: 0.15rem;
}
.page-layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.75fr);
gap: 0.5rem;
}
.page-layout .section-shell {
padding-top: 0;
}
.contact-form {
display: grid;
gap: 0.9rem;
}
.contact-form label {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
}
.contact-form-message {
grid-column: 1 / -1;
}
.contact-aside {
display: grid;
gap: 1rem;
align-content: start;
padding: 1.5rem 1.5rem 1.5rem 0;
}
.content-sidebar {
display: grid;
gap: 1rem;
align-content: start;
}
.site-footer {
display: flex;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid var(--panel-border);
color: var(--text-muted);
}
.site-footer p {
margin: 0;
}
@media (max-width: 900px) {
.hero,
.phase-grid,
.data-grid,
.property-grid,
.content-grid,
.testimonial-grid,
.page-layout {
grid-template-columns: 1fr;
}
.site-header,
.site-footer,
.cta-band {
flex-direction: column;
align-items: flex-start;
}
.contact-aside {
padding: 0 1.5rem 1.5rem;
}
}
@media (max-width: 640px) {
.app-shell {
padding: 0.75rem;
}
.site-header,
.hero,
.section-shell,
.site-footer {
padding: 1rem;
}
.page-hero {
margin: 0.75rem;
padding: 1rem;
}
.metric-grid {
grid-template-columns: 1fr;
}
.property-metrics {
grid-template-columns: 1fr;
}
}
`;
export default function RootLayout({
children,
}: Readonly<{
@@ -24,8 +782,8 @@ export default function RootLayout({
<SiteFooter />
</div>
</div>
<style>{globalStyles}</style>
</body>
</html>
);
}

View File

@@ -1,5 +1,6 @@
import Link from 'next/link';
import { Section } from '@/components/Section';
import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking';
import {
featuredProperties,
locationHighlights,
@@ -20,6 +21,14 @@ const bookingFields = [
{ label: 'Area', value: 'Coastal or rural' },
];
const demoQuote = quoteStay(bookingCatalog[0]!, {
arrivalDate: '2026-07-10',
departureDate: '2026-07-14',
adults: 2,
children: 1,
pets: 0,
});
export default function HomePage() {
return (
<>
@@ -61,13 +70,53 @@ export default function HomePage() {
<input aria-label={field.label} defaultValue={field.value} />
</label>
))}
<button className="btn btn-dark" type="button">
<Link className="btn btn-dark" href="/bookings/new">
Check availability
</button>
</Link>
</form>
<div className="quote-panel" aria-label="Booking quote preview">
<div className={`availability-pill ${demoQuote.available ? 'is-available' : 'is-unavailable'}`}>
{demoQuote.available ? 'Available now' : 'Unavailable'}
</div>
<div className="quote-heading">
<div>
<p className="footer-label">Live quote core</p>
<h3>{demoQuote.propertyName}</h3>
</div>
<strong>{formatPoundsFromCents(demoQuote.totalCents)}</strong>
</div>
<p className="mb-0">
{demoQuote.arrivalDate} to {demoQuote.departureDate} {demoQuote.nights} nights
</p>
<p className="mb-0 text-body-secondary">
Booking hold: {demoQuote.holdExpiresAt ? '30 minutes after checkout starts' : 'not available yet'}
</p>
</div>
</aside>
</section>
<Section
eyebrow="Booking core"
title="Availability and pricing are now based on explicit rules"
description="The site can check dates, block conflicts, and preview a booking total before payment starts. Later tickets can reuse the same core on the property pages and checkout path."
>
<div className="data-grid">
<article className="data-card">
<h3>Availability checks</h3>
<p>
Published properties are filtered by search terms, guest count, pet rules, minimum stay, and any blocked or already-booked date ranges.
</p>
</article>
<article className="data-card">
<h3>Pricing rules</h3>
<p>
The quote core applies seasonal pricing, weekend overrides, guest supplements, and a 30-minute hold window for the booking start step.
</p>
</article>
</div>
</Section>
<Section
id="browse"
eyebrow="Featured stays"
@@ -206,7 +255,7 @@ export default function HomePage() {
<article className="content-card">
<h3>What comes next</h3>
<p>
The next tickets can now focus on the property listing and property detail pages while the public content layer stays reusable.
The next tickets can now focus on the property listing and property detail pages while the public content layer and booking core stay reusable.
</p>
<Link className="inline-link" href="/contact">
Enquire through the contact page

344
src/lib/booking.ts Normal file
View File

@@ -0,0 +1,344 @@
export type BookingSearchInput = {
arrivalDate?: string;
departureDate?: string;
adults: number;
children: number;
pets: number;
location?: string;
propertySlug?: string;
};
type DateRange = {
startDate: string;
endDate: string;
reason: string;
};
type SeasonalRate = {
label: string;
startDate: string;
endDate: string;
nightlyCents: number;
weekendNightlyCents?: number;
};
export type BookingPropertyProfile = {
slug: string;
name: string;
area: string;
summary: string;
sleeps: number;
bedrooms: number;
bathrooms: number;
published: boolean;
petsAllowed: boolean;
minStayNights: number;
baseNightlyCents: number;
weekendNightlyCents?: number;
guestSupplementCents?: number;
seasonalRates: SeasonalRate[];
availabilityBlocks: DateRange[];
confirmedBookings: DateRange[];
};
export type BookingQuote = {
propertySlug: string;
propertyName: string;
area: string;
available: boolean;
nights: number;
arrivalDate?: string;
departureDate?: string;
holdExpiresAt?: string;
reasons: string[];
nightlyRates: Array<{ date: string; label: string; amountCents: number }>;
priceBreakdown: Array<{ label: string; amountCents: number }>;
totalCents: number;
};
export type BookingSearchResult = BookingQuote & {
sleeps: number;
bedrooms: number;
bathrooms: number;
petsAllowed: boolean;
};
const MS_PER_DAY = 24 * 60 * 60 * 1000;
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,
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' }],
},
];
function parseDate(value?: string) {
if (!value) return null;
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDate(date: Date) {
return date.toISOString().slice(0, 10);
}
function addDays(date: Date, days: number) {
return new Date(date.getTime() + days * MS_PER_DAY);
}
function diffInNights(arrival: Date, departure: Date) {
return Math.round((departure.getTime() - arrival.getTime()) / MS_PER_DAY);
}
function rangesOverlap(startA: Date, endA: Date, startB: Date, endB: Date) {
return startA < endB && endA > startB;
}
function formatCurrency(cents: number) {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0,
}).format(cents / 100);
}
function getRateForDate(property: BookingPropertyProfile, night: Date) {
const seasonalRate = property.seasonalRates.find((rate) => {
const start = parseDate(rate.startDate);
const end = parseDate(rate.endDate);
return start && end ? night >= start && night <= end : false;
});
const isWeekend = night.getUTCDay() === 5 || night.getUTCDay() === 6;
if (seasonalRate) {
return {
label: seasonalRate.label,
amountCents: isWeekend && seasonalRate.weekendNightlyCents ? seasonalRate.weekendNightlyCents : seasonalRate.nightlyCents,
};
}
return {
label: isWeekend && property.weekendNightlyCents ? 'Weekend rate' : 'Base rate',
amountCents: isWeekend && property.weekendNightlyCents ? property.weekendNightlyCents : property.baseNightlyCents,
};
}
function collectConflicts(property: BookingPropertyProfile, arrival: Date, departure: Date) {
const conflicts: string[] = [];
for (const block of [...property.availabilityBlocks, ...property.confirmedBookings]) {
const start = parseDate(block.startDate);
const end = parseDate(block.endDate);
if (!start || !end) continue;
if (rangesOverlap(arrival, departure, start, end)) {
conflicts.push(
block.reason === 'CONFIRMED_BOOKING'
? `Booked from ${block.startDate} to ${block.endDate}`
: `Unavailable from ${block.startDate} to ${block.endDate}`,
);
}
}
return conflicts;
}
export function quoteStay(property: BookingPropertyProfile, input: BookingSearchInput): BookingQuote {
const reasons: string[] = [];
const arrival = parseDate(input.arrivalDate);
const departure = parseDate(input.departureDate);
const adults = Number.isFinite(input.adults) ? input.adults : 0;
const children = Number.isFinite(input.children) ? input.children : 0;
const pets = Number.isFinite(input.pets) ? input.pets : 0;
const guestCount = adults + children;
let nights = 0;
if (!arrival || !departure) {
reasons.push('Select arrival and departure dates to check availability.');
} else if (departure <= arrival) {
reasons.push('Departure must be after arrival.');
} else {
nights = diffInNights(arrival, departure);
if (nights < property.minStayNights) {
reasons.push(`Minimum stay for this property is ${property.minStayNights} nights.`);
}
}
if (guestCount > property.sleeps) {
reasons.push(`This property sleeps up to ${property.sleeps} guests.`);
}
if (pets > 0 && !property.petsAllowed) {
reasons.push('Pets are not allowed for this property.');
}
if (arrival && departure && departure > arrival) {
reasons.push(...collectConflicts(property, arrival, departure));
}
const available = reasons.length === 0;
const nightlyRates: BookingQuote['nightlyRates'] = [];
const priceBreakdown: BookingQuote['priceBreakdown'] = [];
if (available && arrival && departure) {
for (let day = 0; day < nights; day += 1) {
const night = addDays(arrival, day);
const nightlyRate = getRateForDate(property, night);
nightlyRates.push({
date: formatDate(night),
label: nightlyRate.label,
amountCents: nightlyRate.amountCents,
});
}
const accommodationCents = nightlyRates.reduce((sum, item) => sum + item.amountCents, 0);
const guestSupplementCents = Math.max(0, guestCount - INCLUDED_GUESTS) * (property.guestSupplementCents ?? 0) * nights;
priceBreakdown.push({ label: 'Accommodation', amountCents: accommodationCents });
if (guestSupplementCents > 0) {
priceBreakdown.push({ label: 'Guest supplement', amountCents: guestSupplementCents });
}
priceBreakdown.push({ label: `Hold for ${BOOKING_HOLD_MINUTES} minutes`, amountCents: 0 });
}
const totalCents = priceBreakdown.reduce((sum, item) => sum + item.amountCents, 0);
return {
propertySlug: property.slug,
propertyName: property.name,
area: property.area,
available,
nights,
arrivalDate: arrival ? formatDate(arrival) : undefined,
departureDate: departure ? formatDate(departure) : undefined,
holdExpiresAt: available ? new Date(Date.now() + BOOKING_HOLD_MINUTES * 60 * 1000).toISOString() : undefined,
reasons,
nightlyRates,
priceBreakdown,
totalCents,
};
}
export function searchBookings(input: BookingSearchInput) {
const locationQuery = input.location?.trim().toLowerCase() ?? '';
const results: BookingSearchResult[] = bookingCatalog
.filter((property) => {
if (!property.published) return false;
if (input.propertySlug && property.slug !== input.propertySlug) return false;
if (!locationQuery) return true;
const searchable = `${property.name} ${property.area} ${property.summary} ${property.slug}`.toLowerCase();
return searchable.includes(locationQuery);
})
.map((property) => ({
...quoteStay(property, input),
sleeps: property.sleeps,
bedrooms: property.bedrooms,
bathrooms: property.bathrooms,
petsAllowed: property.petsAllowed,
}))
.sort((a, b) => {
if (a.available !== b.available) return a.available ? -1 : 1;
if (a.totalCents !== b.totalCents) return a.totalCents - b.totalCents;
return a.propertyName.localeCompare(b.propertyName);
});
return {
search: {
arrivalDate: input.arrivalDate,
departureDate: input.departureDate,
adults: input.adults,
children: input.children,
pets: input.pets,
location: input.location ?? '',
},
results,
};
}
export function formatPoundsFromCents(cents: number) {
return formatCurrency(cents);
}

206
src/lib/payments.test.ts Normal file
View File

@@ -0,0 +1,206 @@
import { BookingStatus, PaymentStatus } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const prismaMock = vi.hoisted(() => ({
property: {
upsert: vi.fn(),
},
booking: {
create: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
},
payment: {
create: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(async (operations: Array<Promise<unknown>>) => Promise.all(operations)),
}));
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}));
import { createBookingCheckout, handleStripeWebhookBody, handleStripeWebhookEvent } from './payments';
describe('payments flow', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'info').mockImplementation(() => {});
});
it('creates a booking checkout with the local fallback handoff when Stripe is not configured', async () => {
prismaMock.property.upsert.mockResolvedValue({ id: 'property_123' });
prismaMock.booking.create.mockResolvedValue({ id: 'booking_123' });
prismaMock.payment.create.mockResolvedValue({ id: 'payment_123' });
const result = await createBookingCheckout({
propertySlug: 'orchard-barn',
arrivalDate: '2026-06-22',
departureDate: '2026-06-25',
adults: 2,
children: 0,
pets: 1,
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
termsAccepted: true,
});
expect(prismaMock.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
propertyId: 'property_123',
status: BookingStatus.PENDING_PAYMENT,
totalCents: expect.any(Number),
currency: 'GBP',
}),
}),
);
expect(prismaMock.payment.create).toHaveBeenCalledWith({
data: {
bookingId: 'booking_123',
amountCents: result.quote.totalCents,
currency: 'GBP',
status: PaymentStatus.REQUIRES_PAYMENT,
},
});
expect(result).toMatchObject({
bookingId: 'booking_123',
paymentId: 'payment_123',
checkoutMode: 'mock',
checkoutUrl: 'http://localhost:3000/bookings/booking_123/checkout',
});
});
it('marks the booking confirmed when Stripe reports a successful payment event', async () => {
prismaMock.payment.update.mockResolvedValue({ id: 'payment_123' });
prismaMock.booking.update.mockResolvedValue({ id: 'booking_123', status: BookingStatus.CONFIRMED });
prismaMock.booking.findUnique.mockResolvedValue({
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
status: BookingStatus.CONFIRMED,
property: {
title: 'Orchard Barn',
},
payment: {
status: PaymentStatus.COMPLETED,
},
});
const result = await handleStripeWebhookEvent({
id: 'evt_success_123',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_test_123',
payment_intent: 'pi_test_123',
metadata: {
bookingId: 'booking_123',
},
},
},
});
expect(prismaMock.payment.update).toHaveBeenCalledWith({
where: { bookingId: 'booking_123' },
data: {
status: PaymentStatus.COMPLETED,
stripeEventId: 'evt_success_123',
stripeCheckoutSessionId: 'cs_test_123',
stripePaymentIntentId: 'pi_test_123',
},
});
expect(prismaMock.booking.update).toHaveBeenCalledWith({
where: { id: 'booking_123' },
data: {
status: BookingStatus.CONFIRMED,
},
});
expect(result).toMatchObject({
bookingId: 'booking_123',
paymentId: 'payment_123',
status: BookingStatus.CONFIRMED,
notification: expect.objectContaining({
subject: 'Booking confirmed: Orchard Barn',
}),
});
});
it('marks the booking failed when Stripe reports an expired or failed payment', async () => {
prismaMock.payment.update.mockResolvedValue({ id: 'payment_123' });
prismaMock.booking.update.mockResolvedValue({ id: 'booking_123', status: BookingStatus.FAILED });
prismaMock.booking.findUnique.mockResolvedValue({
firstName: 'Casey',
lastName: 'Morgan',
email: 'casey@example.com',
arrivalDate: new Date('2026-06-22T00:00:00.000Z'),
departureDate: new Date('2026-06-25T00:00:00.000Z'),
totalCents: 63000,
status: BookingStatus.FAILED,
property: {
title: 'Orchard Barn',
},
payment: {
status: PaymentStatus.FAILED,
},
});
const result = await handleStripeWebhookBody(
JSON.stringify({
id: 'evt_failed_123',
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'pi_test_123',
payment_intent: 'pi_test_123',
metadata: {
bookingId: 'booking_123',
},
},
},
}),
null,
);
expect(prismaMock.payment.update).toHaveBeenCalledWith({
where: { bookingId: 'booking_123' },
data: {
status: PaymentStatus.FAILED,
stripeEventId: 'evt_failed_123',
stripeCheckoutSessionId: 'pi_test_123',
stripePaymentIntentId: 'pi_test_123',
},
});
expect(prismaMock.booking.update).toHaveBeenCalledWith({
where: { id: 'booking_123' },
data: {
status: BookingStatus.FAILED,
},
});
expect(result).toMatchObject({
bookingId: 'booking_123',
paymentId: 'payment_123',
status: BookingStatus.FAILED,
notification: expect.objectContaining({
subject: 'Payment issue for Orchard Barn',
}),
});
});
it('rejects webhook payloads that do not include booking metadata', async () => {
await expect(
handleStripeWebhookEvent({
id: 'evt_missing_123',
type: 'checkout.session.completed',
data: {
object: {},
},
}),
).rejects.toThrow('Webhook payload did not include booking metadata');
});
});

412
src/lib/payments.ts Normal file
View File

@@ -0,0 +1,412 @@
import Stripe from 'stripe';
import { BookingStatus, PaymentStatus } from '@prisma/client';
import { prisma } from '@/lib/prisma';
import {
bookingCatalog,
formatPoundsFromCents,
quoteStay,
type BookingQuote,
type BookingSearchInput,
} from '@/lib/booking';
export type BookingCheckoutInput = BookingSearchInput & {
firstName: string;
lastName: string;
email: string;
phone?: string;
specialRequests?: string;
termsAccepted: boolean;
};
export type BookingCheckoutResult = {
bookingId: string;
paymentId: string;
checkoutUrl: string;
checkoutMode: 'stripe' | 'mock';
quote: BookingQuote;
};
type PaymentEventResult = {
bookingId: string;
paymentId: string;
status: BookingStatus;
notification: NotificationTemplate | null;
};
export type NotificationTemplate = {
subject: string;
preview: string;
lines: string[];
};
const stripeKey = process.env.STRIPE_SECRET_KEY?.trim();
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
const stripeClient = stripeKey ? new Stripe(stripeKey) : null;
function getSiteUrl() {
return (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
}
function findProperty(propertySlug: string) {
return bookingCatalog.find((property) => property.slug === propertySlug);
}
async function ensureDbProperty(propertySlug: string) {
const property = findProperty(propertySlug);
if (!property) {
throw new Error('Unknown property');
}
return prisma.property.upsert({
where: { slug: property.slug },
create: {
slug: property.slug,
title: property.name,
summary: property.summary,
longDescription: property.summary,
locationText: property.area,
sleeps: property.sleeps,
bedrooms: property.bedrooms,
bathrooms: property.bathrooms,
petsAllowed: property.petsAllowed,
published: true,
featured: false,
minStayNights: property.minStayNights,
},
update: {
title: property.name,
summary: property.summary,
longDescription: property.summary,
locationText: property.area,
sleeps: property.sleeps,
bedrooms: property.bedrooms,
bathrooms: property.bathrooms,
petsAllowed: property.petsAllowed,
published: true,
minStayNights: property.minStayNights,
},
});
}
function normalizeRequiredString(value: unknown, field: string) {
if (typeof value !== 'string') {
throw new Error(`${field} is required`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${field} is required`);
}
return trimmed;
}
function normalizeBoolean(value: unknown, field: string) {
if (typeof value !== 'boolean') {
throw new Error(`${field} is required`);
}
return value;
}
function normalizeNumber(value: unknown, field: string) {
const parsed = typeof value === 'string' ? Number(value) : Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${field} must be a number`);
}
return parsed;
}
function getBookingHoldMinutes() {
const parsed = Number(process.env.BOOKING_HOLD_MINUTES || '30');
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
}
function getCheckoutUrls(bookingId: string) {
const baseUrl = getSiteUrl();
return {
successUrl: `${baseUrl}/bookings/${bookingId}?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${baseUrl}/bookings/${bookingId}?checkout=cancelled`,
fallbackUrl: `${baseUrl}/bookings/${bookingId}/checkout`,
};
}
async function buildNotificationForBooking(bookingId: string, success: boolean): Promise<NotificationTemplate | null> {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: { property: true, payment: true },
});
if (!booking) return null;
if (success) {
return {
subject: `Booking confirmed: ${booking.property.title}`,
preview: `${booking.firstName} ${booking.lastName} is now confirmed for ${formatPoundsFromCents(booking.totalCents)}.`,
lines: [
`${booking.property.title} is confirmed.`,
`Guest: ${booking.firstName} ${booking.lastName} <${booking.email}>`,
`Dates: ${booking.arrivalDate.toISOString().slice(0, 10)} to ${booking.departureDate.toISOString().slice(0, 10)}`,
`Total: ${formatPoundsFromCents(booking.totalCents)}`,
`Payment status: ${booking.payment?.status ?? 'unknown'}`,
],
};
}
return {
subject: `Payment issue for ${booking.property.title}`,
preview: `The booking for ${booking.firstName} ${booking.lastName} did not complete payment.`,
lines: [
`Booking: ${booking.property.title}`,
`Guest: ${booking.firstName} ${booking.lastName} <${booking.email}>`,
`Dates: ${booking.arrivalDate.toISOString().slice(0, 10)} to ${booking.departureDate.toISOString().slice(0, 10)}`,
`Current booking state: ${booking.status}`,
`Current payment state: ${booking.payment?.status ?? 'unknown'}`,
],
};
}
async function recordNotification(bookingId: string, success: boolean) {
const notification = await buildNotificationForBooking(bookingId, success);
if (!notification) return null;
console.info('[booking-notification]', JSON.stringify(notification, null, 2));
return notification;
}
async function createStripeSession(bookingId: string, amountCents: number, email: string) {
if (!stripeClient) return null;
const urls = getCheckoutUrls(bookingId);
const session = await stripeClient.checkout.sessions.create({
mode: 'payment',
customer_email: email,
success_url: urls.successUrl,
cancel_url: urls.cancelUrl,
line_items: [
{
quantity: 1,
price_data: {
currency: 'gbp',
product_data: {
name: `Holiday Property Booking ${bookingId.slice(0, 8)}`,
description: 'Booking deposit and reservation payment',
},
unit_amount: amountCents,
},
},
],
metadata: {
bookingId,
},
});
return session;
}
export async function createBookingCheckout(input: BookingCheckoutInput): Promise<BookingCheckoutResult> {
const property = findProperty(input.propertySlug);
if (!property) {
throw new Error('Unknown property');
}
const quote = quoteStay(property, input);
if (!quote.available) {
throw new Error(quote.reasons.join(' '));
}
const firstName = normalizeRequiredString(input.firstName, 'firstName');
const lastName = normalizeRequiredString(input.lastName, 'lastName');
const email = normalizeRequiredString(input.email, 'email');
const termsAccepted = normalizeBoolean(input.termsAccepted, 'termsAccepted');
const adults = normalizeNumber(input.adults, 'adults');
const children = normalizeNumber(input.children, 'children');
const pets = normalizeNumber(input.pets, 'pets');
const holdMinutes = getBookingHoldMinutes();
const holdExpiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const dbProperty = await ensureDbProperty(property.slug);
const booking = await prisma.booking.create({
data: {
propertyId: dbProperty.id,
firstName,
lastName,
email,
phone: input.phone?.trim() || null,
arrivalDate: new Date(`${quote.arrivalDate}T00:00:00.000Z`),
departureDate: new Date(`${quote.departureDate}T00:00:00.000Z`),
adults,
children,
pets,
specialRequests: input.specialRequests?.trim() || null,
termsAccepted,
holdExpiresAt,
totalCents: quote.totalCents,
currency: 'GBP',
status: BookingStatus.PENDING_PAYMENT,
},
});
const payment = await prisma.payment.create({
data: {
bookingId: booking.id,
amountCents: quote.totalCents,
currency: 'GBP',
status: PaymentStatus.REQUIRES_PAYMENT,
},
});
const session = await createStripeSession(booking.id, quote.totalCents, email);
if (session) {
await prisma.payment.update({
where: { id: payment.id },
data: {
stripeCheckoutSessionId: session.id,
},
});
return {
bookingId: booking.id,
paymentId: payment.id,
checkoutUrl: session.url || getCheckoutUrls(booking.id).fallbackUrl,
checkoutMode: 'stripe',
quote,
};
}
return {
bookingId: booking.id,
paymentId: payment.id,
checkoutUrl: getCheckoutUrls(booking.id).fallbackUrl,
checkoutMode: 'mock',
quote,
};
}
async function updatePaymentOutcome(bookingId: string, status: PaymentStatus, bookingStatus: BookingStatus, eventId: string, stripeIds: { checkoutSessionId?: string | null; paymentIntentId?: string | null } = {}) {
const [payment, booking] = await prisma.$transaction([
prisma.payment.update({
where: { bookingId },
data: {
status,
stripeEventId: eventId,
stripeCheckoutSessionId: stripeIds.checkoutSessionId ?? undefined,
stripePaymentIntentId: stripeIds.paymentIntentId ?? undefined,
},
}),
prisma.booking.update({
where: { id: bookingId },
data: {
status: bookingStatus,
},
}),
]);
return { payment, booking };
}
export async function handleStripeWebhookEvent(rawEvent: unknown): Promise<PaymentEventResult> {
const event = rawEvent as { id?: string; type?: string; data?: { object?: Record<string, unknown> } };
const eventId = event.id ?? `dev-${Date.now()}`;
const eventType = event.type ?? 'unknown';
const object = event.data?.object ?? {};
const metadata = (object.metadata ?? {}) as Record<string, string>;
const bookingId = metadata.bookingId;
if (!bookingId) {
throw new Error('Webhook payload did not include booking metadata');
}
if (eventType === 'checkout.session.completed' || eventType === 'payment_intent.succeeded') {
const result = await updatePaymentOutcome(
bookingId,
PaymentStatus.COMPLETED,
BookingStatus.CONFIRMED,
eventId,
{
checkoutSessionId: typeof object.id === 'string' ? object.id : null,
paymentIntentId: typeof object.payment_intent === 'string' ? object.payment_intent : null,
},
);
const notification = await recordNotification(bookingId, true);
return {
bookingId,
paymentId: result.payment.id,
status: BookingStatus.CONFIRMED,
notification,
};
}
if (eventType === 'checkout.session.expired' || eventType === 'payment_intent.payment_failed') {
const result = await updatePaymentOutcome(
bookingId,
PaymentStatus.FAILED,
BookingStatus.FAILED,
eventId,
{
checkoutSessionId: typeof object.id === 'string' ? object.id : null,
paymentIntentId: typeof object.payment_intent === 'string' ? object.payment_intent : null,
},
);
const notification = await recordNotification(bookingId, false);
return {
bookingId,
paymentId: result.payment.id,
status: BookingStatus.FAILED,
notification,
};
}
return {
bookingId,
paymentId: 'unknown',
status: BookingStatus.PENDING_PAYMENT,
notification: null,
};
}
export async function handleStripeWebhookBody(rawBody: string, signature: string | null) {
if (stripeClient && stripeWebhookSecret && signature) {
const event = stripeClient.webhooks.constructEvent(rawBody, signature, stripeWebhookSecret);
return handleStripeWebhookEvent(event);
}
const parsed = JSON.parse(rawBody) as unknown;
return handleStripeWebhookEvent(parsed);
}
export async function simulateCompletedPayment(bookingId: string) {
return handleStripeWebhookEvent({
id: `sim-${bookingId}`,
type: 'checkout.session.completed',
data: {
object: {
id: `cs_sim_${bookingId.slice(0, 8)}`,
payment_intent: `pi_sim_${bookingId.slice(0, 8)}`,
metadata: {
bookingId,
},
},
},
});
}
export async function getBookingCheckoutContext(bookingId: string) {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
property: true,
payment: true,
},
});
return booking;
}
export { stripeWebhookSecret };

View File

@@ -155,7 +155,10 @@ export const contentPages: ContentPage[] = [
sections: [
{
title: 'When will live availability arrive?',
paragraphs: ['Availability and pricing will be added in the dedicated booking and pricing slices that follow this public content work.'],
paragraphs: [
'Availability and pricing now share a reusable core so the public site can check dates and preview a total before checkout.',
'Later tickets will wire that core into the property pages and booking start flow.',
],
},
{
title: 'Can guests still enquire now?',

View File

@@ -6,7 +6,7 @@ test.describe('homepage', () => {
await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Explore featured stays' })).toHaveAttribute('href', '/#browse');
await expect(page.getByRole('link', { name: 'Explore featured stays' })).toHaveAttribute('href', '#browse');
await expect(page.getByRole('link', { name: 'Contact the team' })).toHaveAttribute('href', '/contact');
});
@@ -15,7 +15,7 @@ test.describe('homepage', () => {
await expect(page.getByRole('heading', { name: 'A few properties guests can imagine themselves in' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Editorial content keeps the journey understandable' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Location highlights' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'The site can now speak about place, not just property' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'A clear contact route is already live' })).toBeVisible();
});
});

19
vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
esbuild: {
jsx: 'automatic',
},
test: {
environment: 'node',
restoreMocks: true,
clearMocks: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
});