Compare commits
10 Commits
feature/vi
...
feature/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| bb80906d19 | |||
| ab2f9677fa | |||
| 3d334a6b96 | |||
| d1314f2181 | |||
| 0aaba14300 | |||
| e468876d59 | |||
| b28426594c | |||
| 7b6d2d8603 | |||
| 7b9ae307a5 | |||
| ea7ae9087e |
@@ -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.
|
||||||
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 ["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.
|
||||||
|
|||||||
2228
package-lock.json
generated
2228
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,9 @@
|
|||||||
"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:dev": "prisma migrate dev",
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
@@ -22,14 +23,18 @@
|
|||||||
},
|
},
|
||||||
"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,14 +1,12 @@
|
|||||||
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({
|
await prisma.siteSettings.create({
|
||||||
data: {
|
data: {
|
||||||
businessName: 'Holiday Property Booking',
|
businessName: 'Holiday Property Booking',
|
||||||
@@ -20,6 +18,9 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await seedPropertyInventory();
|
||||||
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -28,4 +29,3 @@ main()
|
|||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,8 @@ export default async function BookingPage({ params, searchParams }: BookingPageP
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paymentStatus = booking.payment?.status ?? 'REQUIRES_PAYMENT';
|
const paymentStatus = booking.payment?.status ?? 'REQUIRES_PAYMENT';
|
||||||
|
const paymentCompleted = paymentStatus === 'COMPLETED';
|
||||||
|
const paymentFailed = paymentStatus === 'FAILED' || booking.status === 'FAILED' || query.checkout === 'cancelled';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -75,7 +77,16 @@ export default async function BookingPage({ params, searchParams }: BookingPageP
|
|||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{booking.payment?.status !== 'COMPLETED' ? (
|
{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' }}>
|
<article className="content-card" style={{ marginTop: '1rem' }}>
|
||||||
<h3>Development fallback</h3>
|
<h3>Development fallback</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export const metadata: Metadata = {
|
|||||||
description: 'Start a holiday property booking, check the live quote core, and continue to checkout.',
|
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) {
|
async function startBooking(formData: FormData) {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
@@ -32,7 +38,12 @@ async function startBooking(formData: FormData) {
|
|||||||
redirect(result.checkoutUrl);
|
redirect(result.checkoutUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewBookingPage() {
|
export default async function NewBookingPage({ searchParams }: NewBookingPageProps) {
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const selectedPropertySlug = resolvedSearchParams?.propertySlug;
|
||||||
|
const selectedProperty =
|
||||||
|
bookingCatalog.find((property) => property.slug === selectedPropertySlug) ?? bookingCatalog[0] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-hero">
|
<section className="page-hero">
|
||||||
@@ -48,12 +59,16 @@ export default function NewBookingPage() {
|
|||||||
<Section
|
<Section
|
||||||
eyebrow="Booking form"
|
eyebrow="Booking form"
|
||||||
title="Create a booking hold"
|
title="Create a booking hold"
|
||||||
description="Enter the stay details and guest information needed before checkout."
|
description={
|
||||||
|
selectedProperty
|
||||||
|
? `Enter the stay details for ${selectedProperty.name} and continue into the checkout flow.`
|
||||||
|
: 'Enter the stay details and guest information needed before checkout.'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<form className="contact-form" action={startBooking}>
|
<form className="contact-form" action={startBooking}>
|
||||||
<label>
|
<label>
|
||||||
<span>Property</span>
|
<span>Property</span>
|
||||||
<select name="propertySlug" defaultValue={bookingCatalog[0]?.slug}>
|
<select name="propertySlug" defaultValue={selectedProperty?.slug}>
|
||||||
{bookingCatalog.map((property) => (
|
{bookingCatalog.map((property) => (
|
||||||
<option key={property.slug} value={property.slug}>
|
<option key={property.slug} value={property.slug}>
|
||||||
{property.name}
|
{property.name}
|
||||||
@@ -120,6 +135,16 @@ export default function NewBookingPage() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<aside className="content-sidebar">
|
<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">
|
<article className="content-card">
|
||||||
<p className="footer-label">Flow</p>
|
<p className="footer-label">Flow</p>
|
||||||
<ul className="admin-bullet-list">
|
<ul className="admin-bullet-list">
|
||||||
|
|||||||
@@ -8,13 +8,25 @@ export const metadata: Metadata = {
|
|||||||
description: 'Get in touch about a holiday stay, a booking question, or a property enquiry.',
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -290,6 +290,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-field input,
|
.search-field input,
|
||||||
|
.search-field select,
|
||||||
.contact-form input,
|
.contact-form input,
|
||||||
.contact-form textarea,
|
.contact-form textarea,
|
||||||
.contact-form select {
|
.contact-form select {
|
||||||
@@ -437,6 +438,16 @@ main {
|
|||||||
background: rgba(255, 255, 255, 0.82);
|
background: rgba(255, 255, 255, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.property-card-image {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% + 2rem);
|
||||||
|
max-width: none;
|
||||||
|
margin: -1rem -1rem 0;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 1.35rem 1.35rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.property-card-top {
|
.property-card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -573,6 +584,79 @@ main {
|
|||||||
letter-spacing: -0.04em;
|
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 {
|
.admin-hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -677,6 +761,66 @@ main {
|
|||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.property-directory-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-directory-form {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
border: 1px solid rgba(26, 23, 20, 0.08);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-actions,
|
||||||
|
.section-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-results-grid {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-result-card {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-filter-list {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-notes {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-card {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-form {
|
.contact-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
@@ -719,12 +863,14 @@ main {
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.hero,
|
.hero,
|
||||||
|
.property-hero,
|
||||||
.phase-grid,
|
.phase-grid,
|
||||||
.data-grid,
|
.data-grid,
|
||||||
.property-grid,
|
.property-grid,
|
||||||
.content-grid,
|
.content-grid,
|
||||||
.testimonial-grid,
|
.testimonial-grid,
|
||||||
.page-layout {
|
.page-layout,
|
||||||
|
.search-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,9 +907,14 @@ main {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.policy-grid,
|
||||||
.property-metrics {
|
.property-metrics {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-field-wide {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Section } from '@/components/Section';
|
import { Section } from '@/components/Section';
|
||||||
import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking';
|
import { bookingCatalog, formatPoundsFromCents, quoteStay } from '@/lib/booking';
|
||||||
|
import { getFallbackPropertyDirectory } from '@/lib/properties';
|
||||||
import {
|
import {
|
||||||
featuredProperties,
|
|
||||||
locationHighlights,
|
locationHighlights,
|
||||||
site,
|
site,
|
||||||
testimonials,
|
testimonials,
|
||||||
@@ -14,13 +14,6 @@ const ctaPoints = [
|
|||||||
'A direct contact route for questions before booking',
|
'A direct contact route for questions before booking',
|
||||||
];
|
];
|
||||||
|
|
||||||
const bookingFields = [
|
|
||||||
{ label: 'Arrival', value: 'Choose a date' },
|
|
||||||
{ label: 'Departure', value: 'Choose a date' },
|
|
||||||
{ label: 'Guests', value: '2 adults' },
|
|
||||||
{ label: 'Area', value: 'Coastal or rural' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const demoQuote = quoteStay(bookingCatalog[0]!, {
|
const demoQuote = quoteStay(bookingCatalog[0]!, {
|
||||||
arrivalDate: '2026-07-10',
|
arrivalDate: '2026-07-10',
|
||||||
departureDate: '2026-07-14',
|
departureDate: '2026-07-14',
|
||||||
@@ -29,7 +22,9 @@ const demoQuote = quoteStay(bookingCatalog[0]!, {
|
|||||||
pets: 0,
|
pets: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function HomePage() {
|
export default async function HomePage() {
|
||||||
|
const featuredProperties = getFallbackPropertyDirectory().filter((property) => property.featured);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="hero" id="top">
|
<section className="hero" id="top">
|
||||||
@@ -39,8 +34,8 @@ export default function HomePage() {
|
|||||||
<p>{site.description}</p>
|
<p>{site.description}</p>
|
||||||
|
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<Link className="btn btn-primary" href="#browse">
|
<Link className="btn btn-primary" href="/properties">
|
||||||
Explore featured stays
|
Browse all properties
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="btn btn-outline-dark" href="/contact">
|
<Link className="btn btn-outline-dark" href="/contact">
|
||||||
Contact the team
|
Contact the team
|
||||||
@@ -59,20 +54,30 @@ export default function HomePage() {
|
|||||||
<p className="footer-label">Search preview</p>
|
<p className="footer-label">Search preview</p>
|
||||||
<strong>Plan the right stay</strong>
|
<strong>Plan the right stay</strong>
|
||||||
<p className="mb-0 text-body-secondary">
|
<p className="mb-0 text-body-secondary">
|
||||||
The booking flow will later use live availability and pricing. This slice keeps the public browsing entry point clear.
|
Search now leads into the live property directory, with filters that reuse the same availability and pricing rules as the booking flow.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="search-panel" aria-label="Availability search preview">
|
<form className="search-panel" aria-label="Availability search" action="/properties">
|
||||||
{bookingFields.map((field) => (
|
<label className="search-field">
|
||||||
<label key={field.label} className="search-field">
|
<span>Arrival</span>
|
||||||
<span>{field.label}</span>
|
<input aria-label="Arrival" type="date" name="arrivalDate" defaultValue="2026-07-10" />
|
||||||
<input aria-label={field.label} defaultValue={field.value} />
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
<label className="search-field">
|
||||||
<Link className="btn btn-dark" href="/bookings/new">
|
<span>Departure</span>
|
||||||
Check availability
|
<input aria-label="Departure" type="date" name="departureDate" defaultValue="2026-07-14" />
|
||||||
</Link>
|
</label>
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Adults</span>
|
||||||
|
<input aria-label="Adults" type="number" name="adults" min="1" max="8" defaultValue="2" />
|
||||||
|
</label>
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Area or property</span>
|
||||||
|
<input aria-label="Area or property" type="text" name="location" defaultValue="Coastal" />
|
||||||
|
</label>
|
||||||
|
<button className="btn btn-dark" type="submit">
|
||||||
|
Search stays
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="quote-panel" aria-label="Booking quote preview">
|
<div className="quote-panel" aria-label="Booking quote preview">
|
||||||
@@ -126,12 +131,17 @@ export default function HomePage() {
|
|||||||
<div className="property-grid">
|
<div className="property-grid">
|
||||||
{featuredProperties.map((property) => (
|
{featuredProperties.map((property) => (
|
||||||
<article key={property.slug} className="property-card">
|
<article key={property.slug} className="property-card">
|
||||||
|
{property.image ? <img className="property-card-image" src={property.image.url} alt={property.image.altText} /> : null}
|
||||||
<div className="property-card-top">
|
<div className="property-card-top">
|
||||||
<div>
|
<div>
|
||||||
<p className="footer-label">{property.area}</p>
|
<p className="footer-label">{property.area}</p>
|
||||||
<h3>{property.name}</h3>
|
<h3>
|
||||||
|
<Link href={`/properties/${property.slug}`}>{property.title}</Link>
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="property-price">{property.priceFrom}</span>
|
<span className="property-price">
|
||||||
|
{property.priceFromCents ? `From ${formatPoundsFromCents(property.priceFromCents)}/night` : 'Price on request'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{property.summary}</p>
|
<p>{property.summary}</p>
|
||||||
<dl className="property-metrics">
|
<dl className="property-metrics">
|
||||||
@@ -149,13 +159,21 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<ul className="tag-list">
|
<ul className="tag-list">
|
||||||
{property.tags.map((tag) => (
|
{property.marketingTags.map((tag) => (
|
||||||
<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>
|
||||||
|
<div className="section-actions">
|
||||||
|
<Link className="btn btn-outline-dark" href="/properties">
|
||||||
|
Open the full property directory
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
|
|||||||
238
src/app/properties/[slug]/page.tsx
Normal file
238
src/app/properties/[slug]/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Section } from '@/components/Section';
|
||||||
|
import { buildFallbackProperty, getPublishedPropertyBySlug } from '@/lib/properties';
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/app/properties/page.tsx
Normal file
303
src/app/properties/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Section } from '@/components/Section';
|
||||||
|
import { formatPoundsFromCents, searchBookings } from '@/lib/booking';
|
||||||
|
import { getFallbackPropertyDirectory } from '@/lib/properties';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
type PropertiesPageProps = {
|
||||||
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function firstValue(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string | undefined, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAmenityList(value: string | undefined) {
|
||||||
|
return value
|
||||||
|
? value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PropertiesPage({ searchParams }: PropertiesPageProps) {
|
||||||
|
const params = (searchParams ? await searchParams : {}) ?? {};
|
||||||
|
const location = firstValue(params.location)?.trim() ?? '';
|
||||||
|
const rawArrivalDate = firstValue(params.arrivalDate);
|
||||||
|
const rawDepartureDate = firstValue(params.departureDate);
|
||||||
|
const rawAdults = firstValue(params.adults);
|
||||||
|
const rawChildren = firstValue(params.children);
|
||||||
|
const rawPets = firstValue(params.pets);
|
||||||
|
const rawBedrooms = firstValue(params.bedrooms);
|
||||||
|
const rawAmenities = firstValue(params.amenities);
|
||||||
|
const rawPetsAllowed = firstValue(params.petsAllowed);
|
||||||
|
const arrivalDate = rawArrivalDate ?? '';
|
||||||
|
const departureDate = rawDepartureDate ?? '';
|
||||||
|
const adults = parseNumber(rawAdults, 2);
|
||||||
|
const children = parseNumber(rawChildren, 0);
|
||||||
|
const pets = parseNumber(rawPets, 0);
|
||||||
|
const minimumBedrooms = parseNumber(rawBedrooms, 0);
|
||||||
|
const requiredAmenities = parseAmenityList(rawAmenities);
|
||||||
|
const petsOnly = rawPetsAllowed === 'true';
|
||||||
|
const hasDateSearch = Boolean(arrivalDate && departureDate);
|
||||||
|
|
||||||
|
const directory = getFallbackPropertyDirectory();
|
||||||
|
const directoryBySlug = new Map(directory.map((property) => [property.slug, property]));
|
||||||
|
const availableAmenities = [...new Set(directory.flatMap((property) => property.amenities))].sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const search = searchBookings({
|
||||||
|
arrivalDate: arrivalDate || undefined,
|
||||||
|
departureDate: departureDate || undefined,
|
||||||
|
adults,
|
||||||
|
children,
|
||||||
|
pets,
|
||||||
|
location: location || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = search.results.filter((result) => {
|
||||||
|
const property = directoryBySlug.get(result.propertySlug);
|
||||||
|
if (!property) return false;
|
||||||
|
if (minimumBedrooms > 0 && property.bedrooms < minimumBedrooms) return false;
|
||||||
|
if (petsOnly && !property.petsAllowed) return false;
|
||||||
|
if (requiredAmenities.length > 0 && !requiredAmenities.every((amenity) => property.amenities.includes(amenity))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeFilters = [
|
||||||
|
location ? `Area or property: ${location}` : null,
|
||||||
|
hasDateSearch ? `Dates: ${arrivalDate} to ${departureDate}` : null,
|
||||||
|
rawAdults ? `Adults: ${adults}` : null,
|
||||||
|
rawChildren ? `Children: ${children}` : null,
|
||||||
|
rawPets ? `Pets: ${pets}` : null,
|
||||||
|
rawBedrooms ? `Bedrooms: ${minimumBedrooms}+` : null,
|
||||||
|
petsOnly ? 'Pet-friendly only' : null,
|
||||||
|
...requiredAmenities.map((amenity) => `Amenity: ${amenity}`),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero property-directory-hero">
|
||||||
|
<div>
|
||||||
|
<p className="brand-kicker">Property directory</p>
|
||||||
|
<h2>Search live stay rules before the booking form starts</h2>
|
||||||
|
<p>
|
||||||
|
This directory uses the actual property inventory and booking-rule core so guests can narrow the list by dates,
|
||||||
|
guest mix, pet rules, bedrooms, and practical stay features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article className="property-callout">
|
||||||
|
<p className="footer-label">What this page covers</p>
|
||||||
|
<strong>{results.length} properties in view</strong>
|
||||||
|
<p>
|
||||||
|
Available stays rise to the top when dates are present, and every card keeps the enquiry and booking routes
|
||||||
|
visible.
|
||||||
|
</p>
|
||||||
|
<dl className="property-metrics">
|
||||||
|
<div>
|
||||||
|
<dt>Filters</dt>
|
||||||
|
<dd>Dates, guests, pets, bedrooms, amenities</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Sorting</dt>
|
||||||
|
<dd>Availability first, then lowest total</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="page-layout property-directory-layout">
|
||||||
|
<Section
|
||||||
|
eyebrow="Search controls"
|
||||||
|
title="Guests can refine the directory without losing the booking context"
|
||||||
|
description="The filters stay in the open and submit as plain query parameters so links are shareable and the page keeps working without client-side state."
|
||||||
|
>
|
||||||
|
<form className="search-panel property-directory-form" aria-label="Property directory search">
|
||||||
|
<div className="search-grid">
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Arrival</span>
|
||||||
|
<input type="date" name="arrivalDate" defaultValue={arrivalDate} />
|
||||||
|
</label>
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Departure</span>
|
||||||
|
<input type="date" name="departureDate" defaultValue={departureDate} />
|
||||||
|
</label>
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Adults</span>
|
||||||
|
<input type="number" name="adults" min="1" max="8" defaultValue={adults} />
|
||||||
|
</label>
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Children</span>
|
||||||
|
<input type="number" name="children" min="0" max="8" defaultValue={children} />
|
||||||
|
</label>
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Pets</span>
|
||||||
|
<input type="number" name="pets" min="0" max="4" defaultValue={pets} />
|
||||||
|
</label>
|
||||||
|
<label className="search-field">
|
||||||
|
<span>Bedrooms</span>
|
||||||
|
<input type="number" name="bedrooms" min="0" max="8" defaultValue={minimumBedrooms} />
|
||||||
|
</label>
|
||||||
|
<label className="search-field search-field-wide">
|
||||||
|
<span>Area or property</span>
|
||||||
|
<input type="text" name="location" defaultValue={location} placeholder="Coastal, harbour, rural..." />
|
||||||
|
</label>
|
||||||
|
<label className="toggle-field">
|
||||||
|
<input type="checkbox" name="petsAllowed" value="true" defaultChecked={petsOnly} />
|
||||||
|
<span>Show pet-friendly properties only</span>
|
||||||
|
</label>
|
||||||
|
<label className="search-field search-field-wide">
|
||||||
|
<span>Amenities</span>
|
||||||
|
<select name="amenities" defaultValue={requiredAmenities.join(',')}>
|
||||||
|
<option value="">Any amenity mix</option>
|
||||||
|
{availableAmenities.map((amenity) => (
|
||||||
|
<option key={amenity} value={amenity}>
|
||||||
|
{amenity}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="search-actions">
|
||||||
|
<button className="btn btn-dark" type="submit">
|
||||||
|
Search stays
|
||||||
|
</button>
|
||||||
|
<Link className="btn btn-outline-dark" href="/properties">
|
||||||
|
Clear filters
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
eyebrow="Results"
|
||||||
|
title="Availability signals stay attached to each property card"
|
||||||
|
description="The card order and booking summaries are driven by the same quote logic used later in the checkout path."
|
||||||
|
>
|
||||||
|
{activeFilters.length ? (
|
||||||
|
<ul className="tag-list directory-filter-list" aria-label="Active filters">
|
||||||
|
{activeFilters.map((filter) => (
|
||||||
|
<li key={filter}>{filter}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="property-grid property-results-grid">
|
||||||
|
{results.map((result) => {
|
||||||
|
const property = directoryBySlug.get(result.propertySlug);
|
||||||
|
if (!property) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={result.propertySlug} className="property-card property-result-card">
|
||||||
|
{property.image ? <img className="property-card-image" src={property.image.url} alt={property.image.altText} /> : null}
|
||||||
|
<div className="property-card-top">
|
||||||
|
<div>
|
||||||
|
<p className="footer-label">{property.area}</p>
|
||||||
|
<h3>
|
||||||
|
<Link href={`/properties/${result.propertySlug}`}>{result.propertyName}</Link>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="property-price">
|
||||||
|
{result.totalCents > 0
|
||||||
|
? formatPoundsFromCents(result.totalCents)
|
||||||
|
: property.priceFromCents
|
||||||
|
? `From ${formatPoundsFromCents(property.priceFromCents)}/night`
|
||||||
|
: 'Price on request'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`availability-pill ${
|
||||||
|
hasDateSearch ? (result.available ? 'is-available' : 'is-unavailable') : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasDateSearch
|
||||||
|
? result.available
|
||||||
|
? 'Available for these dates'
|
||||||
|
: 'Check result details'
|
||||||
|
: 'Browse property overview'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>{property.summary}</p>
|
||||||
|
<dl className="property-metrics">
|
||||||
|
<div>
|
||||||
|
<dt>Sleeps</dt>
|
||||||
|
<dd>{property.sleeps}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bedrooms</dt>
|
||||||
|
<dd>{property.bedrooms}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bathrooms</dt>
|
||||||
|
<dd>{property.bathrooms}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<ul className="tag-list">
|
||||||
|
{property.marketingTags.map((tag) => (
|
||||||
|
<li key={tag}>{tag}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{hasDateSearch && result.reasons.length ? (
|
||||||
|
<ul className="admin-bullet-list availability-notes">
|
||||||
|
{result.reasons.map((reason) => (
|
||||||
|
<li key={reason}>{reason}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : hasDateSearch ? (
|
||||||
|
<div className="availability-list">
|
||||||
|
<div className="availability-item">
|
||||||
|
<strong>{result.nights} nights</strong>
|
||||||
|
<span>
|
||||||
|
{result.arrivalDate} to {result.departureDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="availability-list">
|
||||||
|
<div className="availability-item">
|
||||||
|
<strong>{property.minStayNights} night minimum stay</strong>
|
||||||
|
<span>{property.locationText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="hero-actions">
|
||||||
|
<Link className="btn btn-primary" href={`/bookings/new?propertySlug=${result.propertySlug}`}>
|
||||||
|
Start booking
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-dark" href={`/properties/${result.propertySlug}`}>
|
||||||
|
View details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<article className="content-card empty-state-card">
|
||||||
|
<h3>No properties matched this combination</h3>
|
||||||
|
<p>
|
||||||
|
Try fewer filters or remove the amenity and bedroom limits first. The listing keeps your current query in
|
||||||
|
the URL so it is easy to adjust and retry.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
|
||||||
export type BookingSearchInput = {
|
export type BookingSearchInput = {
|
||||||
arrivalDate?: string;
|
arrivalDate?: string;
|
||||||
departureDate?: string;
|
departureDate?: string;
|
||||||
@@ -68,87 +70,40 @@ const BOOKING_HOLD_MINUTES = 30;
|
|||||||
const INCLUDED_GUESTS = 2;
|
const INCLUDED_GUESTS = 2;
|
||||||
|
|
||||||
export const bookingCatalog: BookingPropertyProfile[] = [
|
export const bookingCatalog: BookingPropertyProfile[] = [
|
||||||
{
|
...propertySeedData.map((property) => {
|
||||||
slug: 'coastal-view-cottage',
|
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||||
name: 'Coastal View Cottage',
|
|
||||||
area: 'Clifftop village',
|
return {
|
||||||
summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.',
|
slug: property.slug,
|
||||||
sleeps: 4,
|
name: property.title,
|
||||||
bedrooms: 2,
|
area: property.area,
|
||||||
bathrooms: 2,
|
summary: property.summary,
|
||||||
|
sleeps: property.sleeps,
|
||||||
|
bedrooms: property.bedrooms,
|
||||||
|
bathrooms: property.bathrooms,
|
||||||
published: true,
|
published: true,
|
||||||
petsAllowed: false,
|
petsAllowed: property.petsAllowed,
|
||||||
minStayNights: 2,
|
minStayNights: property.minStayNights,
|
||||||
baseNightlyCents: 18500,
|
baseNightlyCents: standardRate?.basePriceCents ?? 0,
|
||||||
weekendNightlyCents: 21500,
|
weekendNightlyCents: standardRate?.weekendPriceCents,
|
||||||
guestSupplementCents: 1800,
|
guestSupplementCents: standardRate?.guestDeltaCents,
|
||||||
seasonalRates: [
|
seasonalRates: property.pricingRules
|
||||||
{
|
.filter((rule) => rule.validFrom && rule.validTo)
|
||||||
label: 'Summer high season',
|
.map((rule) => ({
|
||||||
startDate: '2026-06-01',
|
label: rule.label || 'Seasonal rate',
|
||||||
endDate: '2026-09-30',
|
startDate: rule.validFrom || '',
|
||||||
nightlyCents: 22500,
|
endDate: rule.validTo || '',
|
||||||
weekendNightlyCents: 25500,
|
nightlyCents: rule.basePriceCents,
|
||||||
},
|
weekendNightlyCents: rule.weekendPriceCents,
|
||||||
],
|
})),
|
||||||
availabilityBlocks: [
|
availabilityBlocks: property.availabilityBlocks.map((block) => ({
|
||||||
{ startDate: '2026-03-15', endDate: '2026-03-18', reason: 'MAINTENANCE' },
|
startDate: block.startDate,
|
||||||
{ startDate: '2026-08-18', endDate: '2026-08-25', reason: 'OWNER_BLOCKED' },
|
endDate: block.endDate,
|
||||||
],
|
reason: block.reason,
|
||||||
confirmedBookings: [{ startDate: '2026-07-21', endDate: '2026-07-28', reason: 'CONFIRMED_BOOKING' }],
|
})),
|
||||||
},
|
confirmedBookings: [],
|
||||||
{
|
};
|
||||||
slug: 'orchard-barn',
|
}),
|
||||||
name: 'Orchard Barn',
|
|
||||||
area: 'Rural retreat',
|
|
||||||
summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.',
|
|
||||||
sleeps: 6,
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 2,
|
|
||||||
published: true,
|
|
||||||
petsAllowed: true,
|
|
||||||
minStayNights: 3,
|
|
||||||
baseNightlyCents: 21000,
|
|
||||||
weekendNightlyCents: 24000,
|
|
||||||
guestSupplementCents: 1200,
|
|
||||||
seasonalRates: [
|
|
||||||
{
|
|
||||||
label: 'Harvest season',
|
|
||||||
startDate: '2026-09-01',
|
|
||||||
endDate: '2026-10-31',
|
|
||||||
nightlyCents: 23000,
|
|
||||||
weekendNightlyCents: 26000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
availabilityBlocks: [{ startDate: '2026-05-12', endDate: '2026-05-17', reason: 'MAINTENANCE' }],
|
|
||||||
confirmedBookings: [{ startDate: '2026-06-12', endDate: '2026-06-19', reason: 'CONFIRMED_BOOKING' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'harbour-house',
|
|
||||||
name: 'Harbour House',
|
|
||||||
area: 'Harbour front',
|
|
||||||
summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.',
|
|
||||||
sleeps: 5,
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 1,
|
|
||||||
published: true,
|
|
||||||
petsAllowed: false,
|
|
||||||
minStayNights: 3,
|
|
||||||
baseNightlyCents: 16500,
|
|
||||||
weekendNightlyCents: 19000,
|
|
||||||
guestSupplementCents: 1500,
|
|
||||||
seasonalRates: [
|
|
||||||
{
|
|
||||||
label: 'Peak summer',
|
|
||||||
startDate: '2026-07-01',
|
|
||||||
endDate: '2026-08-31',
|
|
||||||
nightlyCents: 19500,
|
|
||||||
weekendNightlyCents: 22500,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
availabilityBlocks: [{ startDate: '2026-06-01', endDate: '2026-06-05', reason: 'OWNER_BLOCKED' }],
|
|
||||||
confirmedBookings: [{ startDate: '2026-08-12', endDate: '2026-08-19', reason: 'CONFIRMED_BOOKING' }],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseDate(value?: string) {
|
function parseDate(value?: string) {
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
344
src/lib/properties.ts
Normal file
344
src/lib/properties.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
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;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type FallbackPropertyDetail = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
area: string;
|
||||||
|
summary: string;
|
||||||
|
longDescription: string;
|
||||||
|
locationText: string;
|
||||||
|
sleeps: number;
|
||||||
|
bedrooms: number;
|
||||||
|
bathrooms: number;
|
||||||
|
petsAllowed: boolean;
|
||||||
|
published: boolean;
|
||||||
|
featured: boolean;
|
||||||
|
minStayNights: number;
|
||||||
|
checkInTime: string;
|
||||||
|
checkOutTime: string;
|
||||||
|
marketingTags: string[];
|
||||||
|
images: Array<{
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
altText: string;
|
||||||
|
primaryImage: boolean;
|
||||||
|
}>;
|
||||||
|
amenities: Array<{
|
||||||
|
amenityId: string;
|
||||||
|
amenity: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
pricingRules: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string | null;
|
||||||
|
basePriceCents: number;
|
||||||
|
weekendPriceCents: number | null;
|
||||||
|
guestDeltaCents: number | null;
|
||||||
|
validFrom: Date | null;
|
||||||
|
validTo: Date | null;
|
||||||
|
}>;
|
||||||
|
availability: Array<{
|
||||||
|
id: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
reason: string;
|
||||||
|
notes: string | null;
|
||||||
|
}>;
|
||||||
|
testimonials: Array<{
|
||||||
|
id: string;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
rating: number | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PropertyDirectoryEntry = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
area: string;
|
||||||
|
summary: string;
|
||||||
|
locationText: string;
|
||||||
|
sleeps: number;
|
||||||
|
bedrooms: number;
|
||||||
|
bathrooms: number;
|
||||||
|
petsAllowed: boolean;
|
||||||
|
featured: boolean;
|
||||||
|
minStayNights: number;
|
||||||
|
marketingTags: string[];
|
||||||
|
image: {
|
||||||
|
url: string;
|
||||||
|
altText: string;
|
||||||
|
} | null;
|
||||||
|
priceFromCents: number | null;
|
||||||
|
amenities: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function slugifyAmenity(name: string) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFallbackProperty(slug: string): FallbackPropertyDetail | null {
|
||||||
|
const seeded = propertySeedData.find((property) => property.slug === slug);
|
||||||
|
if (!seeded) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: seeded.slug,
|
||||||
|
slug: seeded.slug,
|
||||||
|
title: seeded.title,
|
||||||
|
area: seeded.area,
|
||||||
|
summary: seeded.summary,
|
||||||
|
longDescription: seeded.longDescription,
|
||||||
|
locationText: seeded.locationText,
|
||||||
|
sleeps: seeded.sleeps,
|
||||||
|
bedrooms: seeded.bedrooms,
|
||||||
|
bathrooms: seeded.bathrooms,
|
||||||
|
petsAllowed: seeded.petsAllowed,
|
||||||
|
published: true,
|
||||||
|
featured: seeded.featured,
|
||||||
|
minStayNights: seeded.minStayNights,
|
||||||
|
checkInTime: seeded.checkInTime,
|
||||||
|
checkOutTime: seeded.checkOutTime,
|
||||||
|
marketingTags: seeded.marketingTags,
|
||||||
|
images: seeded.images.map((image, index) => ({
|
||||||
|
id: `${seeded.slug}-image-${index}`,
|
||||||
|
url: image.url,
|
||||||
|
altText: image.altText,
|
||||||
|
primaryImage: image.primaryImage ?? index === 0,
|
||||||
|
})),
|
||||||
|
amenities: seeded.amenities.map((amenity, index) => ({
|
||||||
|
amenityId: `${seeded.slug}-amenity-${index}`,
|
||||||
|
amenity: {
|
||||||
|
name: amenity,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
pricingRules: seeded.pricingRules.map((rule, index) => ({
|
||||||
|
id: `${seeded.slug}-pricing-${index}`,
|
||||||
|
label: rule.label || null,
|
||||||
|
basePriceCents: rule.basePriceCents,
|
||||||
|
weekendPriceCents: rule.weekendPriceCents ?? null,
|
||||||
|
guestDeltaCents: rule.guestDeltaCents ?? null,
|
||||||
|
validFrom: rule.validFrom ? new Date(`${rule.validFrom}T00:00:00.000Z`) : null,
|
||||||
|
validTo: rule.validTo ? new Date(`${rule.validTo}T00:00:00.000Z`) : null,
|
||||||
|
})),
|
||||||
|
availability: seeded.availabilityBlocks.map((block, index) => ({
|
||||||
|
id: `${seeded.slug}-availability-${index}`,
|
||||||
|
startDate: new Date(`${block.startDate}T00:00:00.000Z`),
|
||||||
|
endDate: new Date(`${block.endDate}T00:00:00.000Z`),
|
||||||
|
reason: block.reason,
|
||||||
|
notes: block.notes ?? null,
|
||||||
|
})),
|
||||||
|
testimonials: seeded.testimonials.map((testimonial, index) => ({
|
||||||
|
id: `${seeded.slug}-testimonial-${index}`,
|
||||||
|
authorName: testimonial.authorName,
|
||||||
|
content: testimonial.content,
|
||||||
|
rating: testimonial.rating ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFallbackPropertyDirectory(): PropertyDirectoryEntry[] {
|
||||||
|
return propertySeedData.map((property) => {
|
||||||
|
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||||
|
const primaryImage =
|
||||||
|
property.images.find((image) => image.primaryImage) ?? property.images[0] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: property.slug,
|
||||||
|
title: property.title,
|
||||||
|
area: property.area,
|
||||||
|
summary: property.summary,
|
||||||
|
locationText: property.locationText,
|
||||||
|
sleeps: property.sleeps,
|
||||||
|
bedrooms: property.bedrooms,
|
||||||
|
bathrooms: property.bathrooms,
|
||||||
|
petsAllowed: property.petsAllowed,
|
||||||
|
featured: property.featured,
|
||||||
|
minStayNights: property.minStayNights,
|
||||||
|
marketingTags: property.marketingTags,
|
||||||
|
image: primaryImage
|
||||||
|
? {
|
||||||
|
url: primaryImage.url,
|
||||||
|
altText: primaryImage.altText,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
priceFromCents: standardRate?.basePriceCents ?? null,
|
||||||
|
amenities: property.amenities,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -52,45 +54,26 @@ export const site = {
|
|||||||
|
|
||||||
export const primaryNavigation = [
|
export const primaryNavigation = [
|
||||||
{ href: '/', label: 'Home' },
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/properties', label: 'Properties' },
|
||||||
{ href: '/about', label: 'About' },
|
{ href: '/about', label: 'About' },
|
||||||
{ href: '/faqs', label: 'FAQs' },
|
{ href: '/faqs', label: 'FAQs' },
|
||||||
{ href: '/contact', label: 'Contact' },
|
{ href: '/contact', label: 'Contact' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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 = [
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ 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: 'Browse all properties' })).toHaveAttribute('href', '/properties');
|
||||||
await expect(page.getByRole('link', { name: 'Contact the team' })).toHaveAttribute('href', '/contact');
|
await expect(page.getByRole('link', { name: 'Contact the team' })).toHaveAttribute('href', '/contact');
|
||||||
|
await expect(page.getByRole('link', { name: 'Properties' })).toHaveAttribute('href', '/properties');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows the public content sections', async ({ page }) => {
|
test('shows the public content sections', async ({ page }) => {
|
||||||
@@ -15,7 +16,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
34
tests/e2e/properties.spec.ts
Normal file
34
tests/e2e/properties.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('property directory', () => {
|
||||||
|
test('shows the real-data listing page from navigation', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Properties' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/properties');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Search live stay rules before the booking form starts' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Coastal View Cottage' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Start booking' }).first()).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/bookings/new?propertySlug=coastal-view-cottage',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters properties by dates and pet-friendly toggle', async ({ page }) => {
|
||||||
|
await page.goto(
|
||||||
|
'/properties?arrivalDate=2026-07-10&departureDate=2026-07-14&adults=2&children=0&pets=1&petsAllowed=true',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Orchard Barn' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Available for these dates')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Coastal View Cottage' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Harbour House' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an empty state when filters exclude every property', async ({ page }) => {
|
||||||
|
await page.goto('/properties?location=harbour&bedrooms=4');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'No properties matched this combination' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
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