Compare commits
17 Commits
feature/vi
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| c27b4516eb | |||
| 38b18b7881 | |||
| ab2f9677fa | |||
| 3d334a6b96 | |||
| d1314f2181 | |||
| 0aaba14300 | |||
| e468876d59 | |||
| b28426594c | |||
| 7b6d2d8603 | |||
| 7b9ae307a5 | |||
| ea7ae9087e | |||
| 090741e6ad | |||
| dbdca5c023 | |||
| ef5e36b63d | |||
| 42c4482341 | |||
| 6118b9fd91 | |||
| 014307f2ec |
@@ -41,7 +41,27 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
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
|
- name: Setup Node.js
|
||||||
|
if: steps.node-check.outputs.use_existing_node != 'true'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
@@ -72,4 +92,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Playwright suite
|
- name: Run Playwright suite
|
||||||
run: npm run test:e2e
|
run: npm run test:e2e
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
github-server-url: https://git.dumas.ddns.net
|
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
|
- name: Setup Node.js
|
||||||
|
if: steps.node-check.outputs.use_existing_node != 'true'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
@@ -32,4 +52,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,3 +43,6 @@ prisma/dev.db
|
|||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.cache
|
.cache
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
.openclaw/
|
||||||
|
.trash/
|
||||||
|
|||||||
65
09-operating-cadence-and-batch-plan.md
Normal file
65
09-operating-cadence-and-batch-plan.md
Normal 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.
|
||||||
@@ -9,6 +9,7 @@ The deployment model is expected to follow the shared dev, QA, and production br
|
|||||||
- `NEXT_PUBLIC_SITE_URL` should match the deployed environment.
|
- `NEXT_PUBLIC_SITE_URL` should match the deployed environment.
|
||||||
- `DATABASE_URL` should target the environment-specific PostgreSQL instance.
|
- `DATABASE_URL` should target the environment-specific PostgreSQL instance.
|
||||||
- Stripe and email provider secrets live in environment variables.
|
- Stripe and email provider secrets live in environment variables.
|
||||||
|
- The production container applies Prisma migrations and seeds the property inventory before the Next.js server starts so checkout has the required `Property` records.
|
||||||
|
|
||||||
## Port Mapping
|
## Port Mapping
|
||||||
|
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,15 +1,20 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run prisma:generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["sh", "-c", "npm run prisma:migrate:deploy && npm run prisma:seed && node .next/standalone/server.js"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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
|
- 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
|
## Next Build Step
|
||||||
|
|
||||||
- Start with `VIK-108`, then work through `VIK-109` to `VIK-112` in order before pulling from the backlog queue.
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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
|
## 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`
|
6. `06-admin-console.md`
|
||||||
7. `07-seo-accessibility-performance.md`
|
7. `07-seo-accessibility-performance.md`
|
||||||
8. `08-implementation-plan-and-launch-readiness.md`
|
8. `08-implementation-plan-and-launch-readiness.md`
|
||||||
|
9. `09-operating-cadence-and-batch-plan.md`
|
||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- Feature work now lives on this board and should be tracked as separate tickets.
|
||||||
- Use the playbook lane names exactly when routing work.
|
- 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 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.
|
- 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.
|
- 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.
|
- QA promotion is a separate step after dev validation, not part of the merge itself.
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ const compat = new FlatCompat({
|
|||||||
const config = [
|
const config = [
|
||||||
...compat.extends('next/core-web-vitals'),
|
...compat.extends('next/core-web-vitals'),
|
||||||
{
|
{
|
||||||
ignores: ['.next/**', 'node_modules/**'],
|
ignores: ['.next/**', 'node_modules/**', '.trash/**', '.openclaw/**'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
||||||
|
|||||||
2279
package-lock.json
generated
2279
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -5,10 +5,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "node .next/standalone/server.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
"prisma:migrate:dev": "prisma migrate dev",
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
"prisma:seed": "tsx prisma/seed.ts"
|
"prisma:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
@@ -17,18 +19,23 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"next": "^15.0.0",
|
"next": "^15.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"stripe": "^17.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "20.17.6",
|
"@types/node": "20.17.6",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.0",
|
"eslint-config-next": "15.0.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"prisma": "^6.10.1",
|
"prisma": "^6.10.1",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { seedPropertyInventory } from '@/lib/properties';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const existing = await prisma.siteSettings.findFirst();
|
const existing = await prisma.siteSettings.findFirst();
|
||||||
|
|
||||||
if (existing) {
|
if (!existing) {
|
||||||
return;
|
await prisma.siteSettings.create({
|
||||||
|
data: {
|
||||||
|
businessName: 'Holiday Property Booking',
|
||||||
|
tagline: 'Curated stays, clear availability, and a direct booking flow.',
|
||||||
|
contactEmail: 'hello@example.com',
|
||||||
|
defaultSeoTitle: 'Holiday Property Booking',
|
||||||
|
defaultSeoDescription: 'Book holiday properties with live availability, clear pricing, and secure checkout.',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.siteSettings.create({
|
await seedPropertyInventory();
|
||||||
data: {
|
|
||||||
businessName: 'Holiday Property Booking',
|
|
||||||
tagline: 'Curated stays, clear availability, and a direct booking flow.',
|
|
||||||
contactEmail: 'hello@example.com',
|
|
||||||
defaultSeoTitle: 'Holiday Property Booking',
|
|
||||||
defaultSeoDescription: 'Book holiday properties with live availability, clear pricing, and secure checkout.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
@@ -28,4 +29,3 @@ main()
|
|||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
199
src/app/admin/page.tsx
Normal file
199
src/app/admin/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/app/api/booking/search/route.ts
Normal file
23
src/app/api/booking/search/route.ts
Normal 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);
|
||||||
|
}
|
||||||
14
src/app/api/bookings/[bookingId]/simulate-success/route.ts
Normal file
14
src/app/api/bookings/[bookingId]/simulate-success/route.ts
Normal 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);
|
||||||
|
}
|
||||||
85
src/app/api/bookings/checkout/route.test.ts
Normal file
85
src/app/api/bookings/checkout/route.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/app/api/bookings/checkout/route.ts
Normal file
29
src/app/api/bookings/checkout/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/api/stripe/webhook/route.test.ts
Normal file
53
src/app/api/stripe/webhook/route.test.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/app/api/stripe/webhook/route.ts
Normal file
14
src/app/api/stripe/webhook/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/bookings/[bookingId]/checkout/page.test.tsx
Normal file
56
src/app/bookings/[bookingId]/checkout/page.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
108
src/app/bookings/[bookingId]/checkout/page.tsx
Normal file
108
src/app/bookings/[bookingId]/checkout/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/app/bookings/[bookingId]/page.test.tsx
Normal file
89
src/app/bookings/[bookingId]/page.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
136
src/app/bookings/[bookingId]/page.tsx
Normal file
136
src/app/bookings/[bookingId]/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/app/bookings/new/page.tsx
Normal file
168
src/app/bookings/new/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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.',
|
||||||
|
};
|
||||||
|
|
||||||
|
type NewBookingPageProps = {
|
||||||
|
searchParams?: Promise<{
|
||||||
|
propertySlug?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 async function NewBookingPage({ searchParams }: NewBookingPageProps) {
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const selectedPropertySlug = resolvedSearchParams?.propertySlug;
|
||||||
|
const selectedProperty =
|
||||||
|
bookingCatalog.find((property) => property.slug === selectedPropertySlug) ?? bookingCatalog[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero">
|
||||||
|
<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={
|
||||||
|
selectedProperty
|
||||||
|
? `Enter the stay details for ${selectedProperty.name} and continue into the checkout flow.`
|
||||||
|
: 'Enter the stay details and guest information needed before checkout.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form className="contact-form" action={startBooking}>
|
||||||
|
<label>
|
||||||
|
<span>Property</span>
|
||||||
|
<select name="propertySlug" defaultValue={selectedProperty?.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">
|
||||||
|
{selectedProperty ? (
|
||||||
|
<article className="content-card">
|
||||||
|
<p className="footer-label">Selected stay</p>
|
||||||
|
<h3>{selectedProperty.name}</h3>
|
||||||
|
<p>{selectedProperty.summary}</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
{selectedProperty.sleeps} guests · {selectedProperty.bedrooms} bedrooms · {selectedProperty.bathrooms} bathrooms
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
<article className="content-card">
|
||||||
|
<p className="footer-label">Flow</p>
|
||||||
|
<ul className="admin-bullet-list">
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,13 +8,25 @@ export const metadata: Metadata = {
|
|||||||
description: 'Get in touch about a holiday stay, a booking question, or a property enquiry.',
|
description: 'Get in touch about a holiday stay, a booking question, or a property enquiry.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ContactPageProps = {
|
||||||
|
searchParams?: Promise<{
|
||||||
|
property?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
const reasons = [
|
const reasons = [
|
||||||
'Ask about a property before the booking engine is live',
|
'Ask about a property before the booking engine is live',
|
||||||
'Request more details about a location or house rules',
|
'Request more details about a location or house rules',
|
||||||
'Raise a special requirement or accessibility question',
|
'Raise a special requirement or accessibility question',
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default async function ContactPage({ searchParams }: ContactPageProps) {
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const property = resolvedSearchParams?.property?.trim();
|
||||||
|
const messagePlaceholder = property
|
||||||
|
? `Tell us a little about your stay or question for ${property}.`
|
||||||
|
: 'Tell us a little about your stay or question.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-hero">
|
<section className="page-hero">
|
||||||
@@ -29,9 +41,19 @@ export default function ContactPage() {
|
|||||||
<Section
|
<Section
|
||||||
eyebrow="Enquiry form"
|
eyebrow="Enquiry form"
|
||||||
title="Send a message"
|
title="Send a message"
|
||||||
description="This is the public contact entry point for questions that do not need a booking decision yet."
|
description={
|
||||||
|
property
|
||||||
|
? `This enquiry form is pre-routed for ${property} if you want to ask a question before booking.`
|
||||||
|
: 'This is the public contact entry point for questions that do not need a booking decision yet.'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<form className="contact-form">
|
<form className="contact-form">
|
||||||
|
{property ? (
|
||||||
|
<label>
|
||||||
|
<span>Property</span>
|
||||||
|
<input type="text" name="property" defaultValue={property} readOnly />
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<label>
|
<label>
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<input type="text" name="name" placeholder="Your name" />
|
<input type="text" name="name" placeholder="Your name" />
|
||||||
@@ -46,7 +68,7 @@ export default function ContactPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="contact-form-message">
|
<label className="contact-form-message">
|
||||||
<span>Message</span>
|
<span>Message</span>
|
||||||
<textarea name="message" rows={6} placeholder="Tell us a little about your stay or question." />
|
<textarea name="message" rows={6} placeholder={messagePlaceholder} />
|
||||||
</label>
|
</label>
|
||||||
<button className="btn btn-dark" type="button">
|
<button className="btn btn-dark" type="button">
|
||||||
Send enquiry
|
Send enquiry
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import './globals.scss';
|
|
||||||
import { SiteFooter } from '@/components/SiteFooter';
|
import { SiteFooter } from '@/components/SiteFooter';
|
||||||
import { SiteHeader } from '@/components/SiteHeader';
|
import { SiteHeader } from '@/components/SiteHeader';
|
||||||
import { site } from '@/lib/site';
|
import { site } from '@/lib/site';
|
||||||
@@ -9,6 +8,840 @@ export const metadata: Metadata = {
|
|||||||
description: site.description,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-hero,
|
||||||
|
.property-layout {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.85fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-callout {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
border: 1px solid rgba(46, 102, 97, 0.14);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 248, 247, 0.9));
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-callout strong {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-gallery-card {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 220px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background: rgba(244, 239, 231, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-gallery-card.is-primary {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-gallery-card img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-grid,
|
||||||
|
.availability-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(244, 239, 231, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-item span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
||||||
|
.property-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-grid,
|
||||||
|
.property-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -24,8 +857,8 @@ export default function RootLayout({
|
|||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<style>{globalStyles}</style>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Section } from '@/components/Section';
|
import { Section } from '@/components/Section';
|
||||||
|
import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking';
|
||||||
import {
|
import {
|
||||||
featuredProperties,
|
featuredProperties,
|
||||||
locationHighlights,
|
locationHighlights,
|
||||||
@@ -20,6 +21,14 @@ const bookingFields = [
|
|||||||
{ label: 'Area', value: 'Coastal or rural' },
|
{ 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() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -61,13 +70,53 @@ export default function HomePage() {
|
|||||||
<input aria-label={field.label} defaultValue={field.value} />
|
<input aria-label={field.label} defaultValue={field.value} />
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
<button className="btn btn-dark" type="button">
|
<Link className="btn btn-dark" href="/bookings/new">
|
||||||
Check availability
|
Check availability
|
||||||
</button>
|
</Link>
|
||||||
</form>
|
</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>
|
</aside>
|
||||||
</section>
|
</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
|
<Section
|
||||||
id="browse"
|
id="browse"
|
||||||
eyebrow="Featured stays"
|
eyebrow="Featured stays"
|
||||||
@@ -80,7 +129,9 @@ export default function HomePage() {
|
|||||||
<div className="property-card-top">
|
<div className="property-card-top">
|
||||||
<div>
|
<div>
|
||||||
<p className="footer-label">{property.area}</p>
|
<p className="footer-label">{property.area}</p>
|
||||||
<h3>{property.name}</h3>
|
<h3>
|
||||||
|
<Link href={`/properties/${property.slug}`}>{property.name}</Link>
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="property-price">{property.priceFrom}</span>
|
<span className="property-price">{property.priceFrom}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +155,9 @@ export default function HomePage() {
|
|||||||
<li key={tag}>{tag}</li>
|
<li key={tag}>{tag}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<Link className="inline-link" href={`/properties/${property.slug}`}>
|
||||||
|
View property details
|
||||||
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +260,7 @@ export default function HomePage() {
|
|||||||
<article className="content-card">
|
<article className="content-card">
|
||||||
<h3>What comes next</h3>
|
<h3>What comes next</h3>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
<Link className="inline-link" href="/contact">
|
<Link className="inline-link" href="/contact">
|
||||||
Enquire through the contact page
|
Enquire through the contact page
|
||||||
|
|||||||
296
src/app/properties/[slug]/page.tsx
Normal file
296
src/app/properties/[slug]/page.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Section } from '@/components/Section';
|
||||||
|
import { getPublishedPropertyBySlug } from '@/lib/properties';
|
||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
import { site } from '@/lib/site';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
type PropertyPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPounds(cents: number) {
|
||||||
|
return new Intl.NumberFormat('en-GB', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'GBP',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReason(reason: string) {
|
||||||
|
return reason
|
||||||
|
.toLowerCase()
|
||||||
|
.split('_')
|
||||||
|
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackMetadata(slug: string) {
|
||||||
|
const seeded = buildFallbackProperty(slug);
|
||||||
|
if (!seeded) {
|
||||||
|
return {
|
||||||
|
title: site.name,
|
||||||
|
description: site.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${seeded.title} | ${site.name}`,
|
||||||
|
description: seeded.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackProperty(slug: string) {
|
||||||
|
const seeded = propertySeedData.find((property) => property.slug === slug);
|
||||||
|
if (!seeded) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: seeded.slug,
|
||||||
|
slug: seeded.slug,
|
||||||
|
title: seeded.title,
|
||||||
|
summary: seeded.summary,
|
||||||
|
longDescription: seeded.longDescription,
|
||||||
|
locationText: seeded.locationText,
|
||||||
|
sleeps: seeded.sleeps,
|
||||||
|
bedrooms: seeded.bedrooms,
|
||||||
|
bathrooms: seeded.bathrooms,
|
||||||
|
petsAllowed: seeded.petsAllowed,
|
||||||
|
published: true,
|
||||||
|
featured: seeded.featured,
|
||||||
|
minStayNights: seeded.minStayNights,
|
||||||
|
checkInTime: seeded.checkInTime,
|
||||||
|
checkOutTime: seeded.checkOutTime,
|
||||||
|
images: seeded.images.map((image, index) => ({
|
||||||
|
id: `${seeded.slug}-image-${index}`,
|
||||||
|
url: image.url,
|
||||||
|
altText: image.altText,
|
||||||
|
primaryImage: image.primaryImage ?? index === 0,
|
||||||
|
})),
|
||||||
|
amenities: seeded.amenities.map((amenity, index) => ({
|
||||||
|
amenityId: `${seeded.slug}-amenity-${index}`,
|
||||||
|
amenity: {
|
||||||
|
name: amenity,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
pricingRules: seeded.pricingRules.map((rule, index) => ({
|
||||||
|
id: `${seeded.slug}-pricing-${index}`,
|
||||||
|
label: rule.label || null,
|
||||||
|
basePriceCents: rule.basePriceCents,
|
||||||
|
weekendPriceCents: rule.weekendPriceCents ?? null,
|
||||||
|
guestDeltaCents: rule.guestDeltaCents ?? null,
|
||||||
|
validFrom: rule.validFrom ? new Date(`${rule.validFrom}T00:00:00.000Z`) : null,
|
||||||
|
validTo: rule.validTo ? new Date(`${rule.validTo}T00:00:00.000Z`) : null,
|
||||||
|
})),
|
||||||
|
availability: seeded.availabilityBlocks.map((block, index) => ({
|
||||||
|
id: `${seeded.slug}-availability-${index}`,
|
||||||
|
startDate: new Date(`${block.startDate}T00:00:00.000Z`),
|
||||||
|
endDate: new Date(`${block.endDate}T00:00:00.000Z`),
|
||||||
|
reason: block.reason,
|
||||||
|
notes: block.notes ?? null,
|
||||||
|
})),
|
||||||
|
testimonials: seeded.testimonials.map((testimonial, index) => ({
|
||||||
|
id: `${seeded.slug}-testimonial-${index}`,
|
||||||
|
authorName: testimonial.authorName,
|
||||||
|
content: testimonial.content,
|
||||||
|
rating: testimonial.rating ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PropertyPageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const property = await getPublishedPropertyBySlug(slug);
|
||||||
|
if (!property) {
|
||||||
|
return fallbackMetadata(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${property.title} | ${site.name}`,
|
||||||
|
description: property.summary,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return fallbackMetadata(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PropertyDetailPage({ params }: PropertyPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const property = (await getPublishedPropertyBySlug(slug)) ?? buildFallbackProperty(slug);
|
||||||
|
if (!property) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryImage = property.images[0] ?? null;
|
||||||
|
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||||
|
const seasonalRates = property.pricingRules.filter((rule) => rule.validFrom && rule.validTo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero property-hero">
|
||||||
|
<div>
|
||||||
|
<p className="brand-kicker">Property detail</p>
|
||||||
|
<h2>{property.title}</h2>
|
||||||
|
<p>{property.summary}</p>
|
||||||
|
<div className="hero-actions">
|
||||||
|
<Link className="btn btn-primary" href={`/bookings/new?propertySlug=${property.slug}`}>
|
||||||
|
Check availability
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-dark" href={`/contact?property=${encodeURIComponent(property.title)}`}>
|
||||||
|
Ask a question first
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article className="property-callout">
|
||||||
|
<p className="footer-label">Booking context</p>
|
||||||
|
<strong>{standardRate ? `From ${formatPounds(standardRate.basePriceCents)} per night` : 'Price on request'}</strong>
|
||||||
|
<p>{property.locationText}</p>
|
||||||
|
<dl className="property-metrics">
|
||||||
|
<div>
|
||||||
|
<dt>Sleeps</dt>
|
||||||
|
<dd>{property.sleeps}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bedrooms</dt>
|
||||||
|
<dd>{property.bedrooms}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bathrooms</dt>
|
||||||
|
<dd>{property.bathrooms}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="page-layout property-layout">
|
||||||
|
<Section
|
||||||
|
eyebrow="Gallery and overview"
|
||||||
|
title="Everything a guest needs before starting the booking flow"
|
||||||
|
description="This page is intentionally practical first: imagery, essentials, stay rules, and a clear route into booking or enquiry."
|
||||||
|
>
|
||||||
|
<div className="property-gallery" aria-label={`${property.title} gallery`}>
|
||||||
|
{property.images.map((image) => (
|
||||||
|
<figure key={image.id} className={`property-gallery-card ${image.primaryImage ? 'is-primary' : ''}`}>
|
||||||
|
<img src={image.url} alt={image.altText} />
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-stack">
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>About this stay</h3>
|
||||||
|
<p>{property.longDescription}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Amenities</h3>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{property.amenities.map((item) => (
|
||||||
|
<li key={item.amenityId}>{item.amenity.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Stay policies and practical details</h3>
|
||||||
|
<div className="policy-grid">
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.minStayNights}</strong>
|
||||||
|
<span>Minimum nights</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.checkInTime ?? '16:00'}</strong>
|
||||||
|
<span>Check-in</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.checkOutTime ?? '10:00'}</strong>
|
||||||
|
<span>Check-out</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.petsAllowed ? 'Allowed' : 'Not allowed'}</strong>
|
||||||
|
<span>Pets</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<aside className="content-sidebar">
|
||||||
|
<article className="content-card">
|
||||||
|
<p className="footer-label">Primary image</p>
|
||||||
|
<h3>{primaryImage ? primaryImage.altText : 'Property overview'}</h3>
|
||||||
|
<p className="mb-0">
|
||||||
|
{primaryImage ? 'The first gallery image sets the main expectation for the stay.' : property.summary}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Availability and pricing context</h3>
|
||||||
|
<ul className="admin-bullet-list">
|
||||||
|
<li>{standardRate ? `Base nightly rate: ${formatPounds(standardRate.basePriceCents)}` : 'Rate available on request'}</li>
|
||||||
|
<li>{standardRate?.weekendPriceCents ? `Weekend rate: ${formatPounds(standardRate.weekendPriceCents)}` : 'Weekend pricing follows the base rate'}</li>
|
||||||
|
<li>{standardRate?.guestDeltaCents ? `Additional guest supplement: ${formatPounds(standardRate.guestDeltaCents)} per night` : 'No guest supplement applies'}</li>
|
||||||
|
</ul>
|
||||||
|
{seasonalRates.length ? (
|
||||||
|
<div className="availability-list">
|
||||||
|
{seasonalRates.map((rule) => (
|
||||||
|
<div key={rule.id} className="availability-item">
|
||||||
|
<strong>{rule.label || 'Seasonal rate'}</strong>
|
||||||
|
<span>
|
||||||
|
{formatDate(rule.validFrom as Date)} to {formatDate(rule.validTo as Date)} · {formatPounds(rule.basePriceCents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Known unavailable dates</h3>
|
||||||
|
<div className="availability-list">
|
||||||
|
{property.availability.length ? (
|
||||||
|
property.availability.map((block) => (
|
||||||
|
<div key={block.id} className="availability-item">
|
||||||
|
<strong>{formatReason(block.reason)}</strong>
|
||||||
|
<span>
|
||||||
|
{formatDate(block.startDate)} to {formatDate(block.endDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="mb-0">No blocked dates are currently published for this property.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Guest feedback</h3>
|
||||||
|
<div className="content-stack">
|
||||||
|
{property.testimonials.map((testimonial) => (
|
||||||
|
<blockquote key={testimonial.id} className="testimonial-card">
|
||||||
|
<p>{testimonial.content}</p>
|
||||||
|
<footer>
|
||||||
|
<strong>{testimonial.authorName}</strong>
|
||||||
|
<span>{testimonial.rating ? `${testimonial.rating}/5 rating` : 'Published guest feedback'}</span>
|
||||||
|
</footer>
|
||||||
|
</blockquote>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
src/lib/booking.ts
Normal file
299
src/lib/booking.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
|
||||||
|
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[] = [
|
||||||
|
...propertySeedData.map((property) => {
|
||||||
|
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: property.slug,
|
||||||
|
name: property.title,
|
||||||
|
area: property.area,
|
||||||
|
summary: property.summary,
|
||||||
|
sleeps: property.sleeps,
|
||||||
|
bedrooms: property.bedrooms,
|
||||||
|
bathrooms: property.bathrooms,
|
||||||
|
published: true,
|
||||||
|
petsAllowed: property.petsAllowed,
|
||||||
|
minStayNights: property.minStayNights,
|
||||||
|
baseNightlyCents: standardRate?.basePriceCents ?? 0,
|
||||||
|
weekendNightlyCents: standardRate?.weekendPriceCents,
|
||||||
|
guestSupplementCents: standardRate?.guestDeltaCents,
|
||||||
|
seasonalRates: property.pricingRules
|
||||||
|
.filter((rule) => rule.validFrom && rule.validTo)
|
||||||
|
.map((rule) => ({
|
||||||
|
label: rule.label || 'Seasonal rate',
|
||||||
|
startDate: rule.validFrom || '',
|
||||||
|
endDate: rule.validTo || '',
|
||||||
|
nightlyCents: rule.basePriceCents,
|
||||||
|
weekendNightlyCents: rule.weekendPriceCents,
|
||||||
|
})),
|
||||||
|
availabilityBlocks: property.availabilityBlocks.map((block) => ({
|
||||||
|
startDate: block.startDate,
|
||||||
|
endDate: block.endDate,
|
||||||
|
reason: block.reason,
|
||||||
|
})),
|
||||||
|
confirmedBookings: [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseDate(value?: string) {
|
||||||
|
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
206
src/lib/payments.test.ts
Normal 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
412
src/lib/payments.ts
Normal 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 };
|
||||||
179
src/lib/properties.ts
Normal file
179
src/lib/properties.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
|
||||||
|
const propertyInclude = {
|
||||||
|
amenities: {
|
||||||
|
include: {
|
||||||
|
amenity: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
orderBy: [{ primaryImage: 'desc' }, { displayOrder: 'asc' }],
|
||||||
|
},
|
||||||
|
pricingRules: {
|
||||||
|
orderBy: [{ validFrom: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
orderBy: { startDate: 'asc' },
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { displayOrder: 'asc' },
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PropertyInclude;
|
||||||
|
|
||||||
|
export type PropertyDetailRecord = Prisma.PropertyGetPayload<{
|
||||||
|
include: typeof propertyInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function slugifyAmenity(name: string) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedPropertyInventory() {
|
||||||
|
const amenityIdByName = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const amenityName of new Set(propertySeedData.flatMap((property) => property.amenities))) {
|
||||||
|
const amenity = await prisma.amenity.upsert({
|
||||||
|
where: { slug: slugifyAmenity(amenityName) },
|
||||||
|
create: {
|
||||||
|
slug: slugifyAmenity(amenityName),
|
||||||
|
name: amenityName,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: amenityName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
amenityIdByName.set(amenityName, amenity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const property of propertySeedData) {
|
||||||
|
const record = await prisma.property.upsert({
|
||||||
|
where: { slug: property.slug },
|
||||||
|
create: {
|
||||||
|
slug: property.slug,
|
||||||
|
title: property.title,
|
||||||
|
summary: property.summary,
|
||||||
|
longDescription: property.longDescription,
|
||||||
|
locationText: property.locationText,
|
||||||
|
sleeps: property.sleeps,
|
||||||
|
bedrooms: property.bedrooms,
|
||||||
|
bathrooms: property.bathrooms,
|
||||||
|
petsAllowed: property.petsAllowed,
|
||||||
|
published: true,
|
||||||
|
featured: property.featured,
|
||||||
|
minStayNights: property.minStayNights,
|
||||||
|
checkInTime: property.checkInTime,
|
||||||
|
checkOutTime: property.checkOutTime,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: property.title,
|
||||||
|
summary: property.summary,
|
||||||
|
longDescription: property.longDescription,
|
||||||
|
locationText: property.locationText,
|
||||||
|
sleeps: property.sleeps,
|
||||||
|
bedrooms: property.bedrooms,
|
||||||
|
bathrooms: property.bathrooms,
|
||||||
|
petsAllowed: property.petsAllowed,
|
||||||
|
published: true,
|
||||||
|
featured: property.featured,
|
||||||
|
minStayNights: property.minStayNights,
|
||||||
|
checkInTime: property.checkInTime,
|
||||||
|
checkOutTime: property.checkOutTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.propertyAmenity.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.propertyImage.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.pricingRule.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.availabilityBlock.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.testimonial.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
|
||||||
|
await prisma.propertyAmenity.createMany({
|
||||||
|
data: property.amenities.map((amenityName) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
amenityId: amenityIdByName.get(amenityName) || '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.propertyImage.createMany({
|
||||||
|
data: property.images.map((image, imageIndex) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
url: image.url,
|
||||||
|
altText: image.altText,
|
||||||
|
displayOrder: imageIndex,
|
||||||
|
primaryImage: image.primaryImage ?? imageIndex === 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.pricingRule.createMany({
|
||||||
|
data: property.pricingRules.map((rule) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
label: rule.label || null,
|
||||||
|
basePriceCents: rule.basePriceCents,
|
||||||
|
weekendPriceCents: rule.weekendPriceCents ?? null,
|
||||||
|
guestDeltaCents: rule.guestDeltaCents ?? null,
|
||||||
|
validFrom: rule.validFrom ? new Date(`${rule.validFrom}T00:00:00.000Z`) : null,
|
||||||
|
validTo: rule.validTo ? new Date(`${rule.validTo}T00:00:00.000Z`) : null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.availabilityBlock.createMany({
|
||||||
|
data: property.availabilityBlocks.map((block) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
startDate: new Date(`${block.startDate}T00:00:00.000Z`),
|
||||||
|
endDate: new Date(`${block.endDate}T00:00:00.000Z`),
|
||||||
|
reason: block.reason,
|
||||||
|
notes: block.notes ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.testimonial.createMany({
|
||||||
|
data: property.testimonials.map((testimonial, testimonialIndex) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
authorName: testimonial.authorName,
|
||||||
|
content: testimonial.content,
|
||||||
|
rating: testimonial.rating ?? null,
|
||||||
|
published: true,
|
||||||
|
displayOrder: testimonialIndex,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSeededProperties() {
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seededCount = await prisma.property.count({
|
||||||
|
where: {
|
||||||
|
slug: {
|
||||||
|
in: propertySeedData.map((property) => property.slug),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const imageCount = await prisma.propertyImage.count();
|
||||||
|
|
||||||
|
if (seededCount === propertySeedData.length && imageCount > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await seedPropertyInventory();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublishedPropertyBySlug(slug: string) {
|
||||||
|
const hasDatabase = await ensureSeededProperties();
|
||||||
|
if (!hasDatabase) return null;
|
||||||
|
|
||||||
|
return prisma.property.findFirst({
|
||||||
|
where: { slug, published: true },
|
||||||
|
include: propertyInclude,
|
||||||
|
});
|
||||||
|
}
|
||||||
258
src/lib/propertySeedData.ts
Normal file
258
src/lib/propertySeedData.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
type SeedImage = {
|
||||||
|
url: string;
|
||||||
|
altText: string;
|
||||||
|
primaryImage?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedPricingRule = {
|
||||||
|
label?: string;
|
||||||
|
basePriceCents: number;
|
||||||
|
weekendPriceCents?: number;
|
||||||
|
guestDeltaCents?: number;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedAvailabilityBlock = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
reason: 'MAINTENANCE' | 'OWNER_BLOCKED' | 'BASE_RULE' | 'OTHER';
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedTestimonial = {
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
rating?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeedProperty = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
area: string;
|
||||||
|
summary: string;
|
||||||
|
longDescription: string;
|
||||||
|
locationText: string;
|
||||||
|
sleeps: number;
|
||||||
|
bedrooms: number;
|
||||||
|
bathrooms: number;
|
||||||
|
petsAllowed: boolean;
|
||||||
|
featured: boolean;
|
||||||
|
minStayNights: number;
|
||||||
|
checkInTime: string;
|
||||||
|
checkOutTime: string;
|
||||||
|
marketingTags: string[];
|
||||||
|
images: SeedImage[];
|
||||||
|
amenities: string[];
|
||||||
|
pricingRules: SeedPricingRule[];
|
||||||
|
availabilityBlocks: SeedAvailabilityBlock[];
|
||||||
|
testimonials: SeedTestimonial[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const propertySeedData: SeedProperty[] = [
|
||||||
|
{
|
||||||
|
slug: 'coastal-view-cottage',
|
||||||
|
title: 'Coastal View Cottage',
|
||||||
|
area: 'Clifftop village',
|
||||||
|
summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.',
|
||||||
|
longDescription:
|
||||||
|
'Coastal View Cottage is set above the bay with a sunny terrace, two calm bedrooms, and a living space designed for slower mornings after coastal walks. The stay is positioned for guests who want practical comfort first, then the sea view to do the rest of the work.',
|
||||||
|
locationText:
|
||||||
|
'The cottage sits in a clifftop village a short walk from the harbour path, local bakery, and a small beach reached by steps down the headland.',
|
||||||
|
sleeps: 4,
|
||||||
|
bedrooms: 2,
|
||||||
|
bathrooms: 2,
|
||||||
|
petsAllowed: false,
|
||||||
|
featured: true,
|
||||||
|
minStayNights: 2,
|
||||||
|
checkInTime: '16:00',
|
||||||
|
checkOutTime: '10:00',
|
||||||
|
marketingTags: ['Sea views', 'Family-friendly', 'Short breaks'],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
|
||||||
|
altText: 'Sunlit sitting room with a wide sea-facing window and light wood furniture.',
|
||||||
|
primaryImage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
altText: 'Terrace seating overlooking the coastline in late afternoon light.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=900&q=80',
|
||||||
|
altText: 'Bedroom with neutral linen and coastal tones.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
amenities: ['Sea view terrace', 'Fast Wi-Fi', 'Family dining kitchen', 'Parking for one car', 'Walk-in shower'],
|
||||||
|
pricingRules: [
|
||||||
|
{
|
||||||
|
label: 'Standard season',
|
||||||
|
basePriceCents: 18500,
|
||||||
|
weekendPriceCents: 21500,
|
||||||
|
guestDeltaCents: 1800,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Summer high season',
|
||||||
|
basePriceCents: 22500,
|
||||||
|
weekendPriceCents: 25500,
|
||||||
|
guestDeltaCents: 1800,
|
||||||
|
validFrom: '2026-06-01',
|
||||||
|
validTo: '2026-09-30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availabilityBlocks: [
|
||||||
|
{
|
||||||
|
startDate: '2026-03-15',
|
||||||
|
endDate: '2026-03-18',
|
||||||
|
reason: 'MAINTENANCE',
|
||||||
|
notes: 'Spring maintenance window',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: '2026-08-18',
|
||||||
|
endDate: '2026-08-25',
|
||||||
|
reason: 'OWNER_BLOCKED',
|
||||||
|
notes: 'Owner stay',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
testimonials: [
|
||||||
|
{
|
||||||
|
authorName: 'Sophie M.',
|
||||||
|
content: 'We could tell exactly what the stay would feel like before we booked, and the sea view more than held up.',
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'orchard-barn',
|
||||||
|
title: 'Orchard Barn',
|
||||||
|
area: 'Rural retreat',
|
||||||
|
summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.',
|
||||||
|
longDescription:
|
||||||
|
'Orchard Barn gives groups more room to spread out without losing the warmth of a rural stay. The main space stays open and sociable, while the bedrooms and garden edges keep it comfortable for longer weekends and family trips.',
|
||||||
|
locationText:
|
||||||
|
'Set back from a quiet lane beside orchards and footpaths, the barn is well placed for walking routes, local pubs, and slower countryside stays.',
|
||||||
|
sleeps: 6,
|
||||||
|
bedrooms: 3,
|
||||||
|
bathrooms: 2,
|
||||||
|
petsAllowed: true,
|
||||||
|
featured: true,
|
||||||
|
minStayNights: 3,
|
||||||
|
checkInTime: '15:00',
|
||||||
|
checkOutTime: '10:30',
|
||||||
|
marketingTags: ['Pets considered', 'Hot tub', 'Long weekends'],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1500&q=80',
|
||||||
|
altText: 'Converted barn living area with high ceilings and exposed timber beams.',
|
||||||
|
primaryImage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1100&q=80',
|
||||||
|
altText: 'Outdoor dining and garden edge beside the orchard.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=950&q=80',
|
||||||
|
altText: 'Bedroom with vaulted ceiling and soft rural palette.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
amenities: ['Hot tub', 'Dog-friendly utility area', 'Large dining table', 'Wood burner', 'Private garden'],
|
||||||
|
pricingRules: [
|
||||||
|
{
|
||||||
|
label: 'Standard season',
|
||||||
|
basePriceCents: 21000,
|
||||||
|
weekendPriceCents: 24000,
|
||||||
|
guestDeltaCents: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Harvest season',
|
||||||
|
basePriceCents: 23000,
|
||||||
|
weekendPriceCents: 26000,
|
||||||
|
guestDeltaCents: 1200,
|
||||||
|
validFrom: '2026-09-01',
|
||||||
|
validTo: '2026-10-31',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availabilityBlocks: [
|
||||||
|
{
|
||||||
|
startDate: '2026-05-12',
|
||||||
|
endDate: '2026-05-17',
|
||||||
|
reason: 'MAINTENANCE',
|
||||||
|
notes: 'Hot tub service and garden works',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
testimonials: [
|
||||||
|
{
|
||||||
|
authorName: 'Daniel K.',
|
||||||
|
content: 'The layout made group planning easy, and the practical details answered the usual pre-booking questions.',
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'harbour-house',
|
||||||
|
title: 'Harbour House',
|
||||||
|
area: 'Harbour front',
|
||||||
|
summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.',
|
||||||
|
longDescription:
|
||||||
|
'Harbour House is a walkable base for guests who want the town on the doorstep. It works well for couples who want extra room or smaller groups who care more about location and simple access than seclusion.',
|
||||||
|
locationText:
|
||||||
|
'The house fronts the harbour road with quick access to cafés, restaurants, boat trips, and an easy evening walk along the water.',
|
||||||
|
sleeps: 5,
|
||||||
|
bedrooms: 3,
|
||||||
|
bathrooms: 1,
|
||||||
|
petsAllowed: false,
|
||||||
|
featured: true,
|
||||||
|
minStayNights: 3,
|
||||||
|
checkInTime: '16:00',
|
||||||
|
checkOutTime: '10:00',
|
||||||
|
marketingTags: ['Walkable', 'Town stay', 'Flexible dates'],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1450&q=80',
|
||||||
|
altText: 'Townhouse sitting room near the harbour with layered textures and soft light.',
|
||||||
|
primaryImage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1080&q=80',
|
||||||
|
altText: 'Dining space with views toward the harbour street.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=920&q=80',
|
||||||
|
altText: 'Bedroom styled for a short waterside stay.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
amenities: ['Harbour access', 'Walkable restaurants', 'Fast broadband', 'Compact workspace', 'Flexible sleeping setup'],
|
||||||
|
pricingRules: [
|
||||||
|
{
|
||||||
|
label: 'Standard season',
|
||||||
|
basePriceCents: 16500,
|
||||||
|
weekendPriceCents: 19000,
|
||||||
|
guestDeltaCents: 1500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Peak summer',
|
||||||
|
basePriceCents: 19500,
|
||||||
|
weekendPriceCents: 22500,
|
||||||
|
guestDeltaCents: 1500,
|
||||||
|
validFrom: '2026-07-01',
|
||||||
|
validTo: '2026-08-31',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availabilityBlocks: [
|
||||||
|
{
|
||||||
|
startDate: '2026-06-01',
|
||||||
|
endDate: '2026-06-05',
|
||||||
|
reason: 'OWNER_BLOCKED',
|
||||||
|
notes: 'Harbour festival owner use',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
testimonials: [
|
||||||
|
{
|
||||||
|
authorName: 'Priya R.',
|
||||||
|
content: 'Perfect for a car-light stay. We could see what was included, where it was, and how to get started without any dead ends.',
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
|
||||||
export type FeaturedProperty = {
|
export type FeaturedProperty = {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,39 +60,19 @@ export const primaryNavigation = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const featuredProperties: FeaturedProperty[] = [
|
export const featuredProperties: FeaturedProperty[] = [
|
||||||
{
|
...propertySeedData
|
||||||
slug: 'coastal-view-cottage',
|
.filter((property) => property.featured)
|
||||||
name: 'Coastal View Cottage',
|
.map((property) => ({
|
||||||
area: 'Clifftop village',
|
slug: property.slug,
|
||||||
summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.',
|
name: property.title,
|
||||||
sleeps: 4,
|
area: property.area,
|
||||||
bedrooms: 2,
|
summary: property.summary,
|
||||||
bathrooms: 2,
|
sleeps: property.sleeps,
|
||||||
priceFrom: 'From £185/night',
|
bedrooms: property.bedrooms,
|
||||||
tags: ['Sea views', 'Family-friendly', 'Short breaks'],
|
bathrooms: property.bathrooms,
|
||||||
},
|
priceFrom: `From £${Math.round(property.pricingRules[0]?.basePriceCents ? property.pricingRules[0].basePriceCents / 100 : 0)}/night`,
|
||||||
{
|
tags: property.marketingTags,
|
||||||
slug: 'orchard-barn',
|
})),
|
||||||
name: 'Orchard Barn',
|
|
||||||
area: 'Rural retreat',
|
|
||||||
summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.',
|
|
||||||
sleeps: 6,
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 2,
|
|
||||||
priceFrom: 'From £210/night',
|
|
||||||
tags: ['Pets considered', 'Hot tub', 'Long weekends'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'harbour-house',
|
|
||||||
name: 'Harbour House',
|
|
||||||
area: 'Harbour front',
|
|
||||||
summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.',
|
|
||||||
sleeps: 5,
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 1,
|
|
||||||
priceFrom: 'From £165/night',
|
|
||||||
tags: ['Walkable', 'Town stay', 'Flexible dates'],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const locationHighlights = [
|
export const locationHighlights = [
|
||||||
@@ -155,7 +137,10 @@ export const contentPages: ContentPage[] = [
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: 'When will live availability arrive?',
|
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?',
|
title: 'Can guests still enquire now?',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ test.describe('homepage', () => {
|
|||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible();
|
||||||
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
|
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: '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');
|
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: '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: '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();
|
await expect(page.getByRole('heading', { name: 'A clear contact route is already live' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
36
tests/e2e/property-detail.spec.ts
Normal file
36
tests/e2e/property-detail.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('property detail flow', () => {
|
||||||
|
test('opens a property detail page and carries the guest into booking and enquiry entry points', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'View property details' }).first().click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/properties\/coastal-view-cottage$/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Coastal View Cottage' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Everything a guest needs before starting the booking flow' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Check availability' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/bookings/new?propertySlug=coastal-view-cottage',
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('link', { name: 'Ask a question first' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/contact?property=Coastal%20View%20Cottage',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Check availability' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/bookings\/new\?propertySlug=coastal-view-cottage$/);
|
||||||
|
await expect(page.locator('select[name="propertySlug"]')).toHaveValue('coastal-view-cottage');
|
||||||
|
|
||||||
|
await page.goto('/properties/coastal-view-cottage');
|
||||||
|
await page.getByRole('link', { name: 'Ask a question first' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/contact\?property=Coastal%20View%20Cottage$/);
|
||||||
|
await expect(page.locator('input[name="property"]')).toHaveValue('Coastal View Cottage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a 404 for an unknown property slug', async ({ page }) => {
|
||||||
|
const response = await page.goto('/properties/not-a-real-property');
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
19
vitest.config.ts
Normal file
19
vitest.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user