Compare commits
11 Commits
feature/vi
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| c27b4516eb | |||
| 38b18b7881 | |||
| 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.
|
||||||
@@ -9,6 +9,7 @@ The deployment model is expected to follow the shared dev, QA, and production br
|
|||||||
- `NEXT_PUBLIC_SITE_URL` should match the deployed environment.
|
- `NEXT_PUBLIC_SITE_URL` should match the deployed environment.
|
||||||
- `DATABASE_URL` should target the environment-specific PostgreSQL instance.
|
- `DATABASE_URL` should target the environment-specific PostgreSQL instance.
|
||||||
- Stripe and email provider secrets live in environment variables.
|
- Stripe and email provider secrets live in environment variables.
|
||||||
|
- The production container applies Prisma migrations and seeds the property inventory before the Next.js server starts so checkout has the required `Property` records.
|
||||||
|
|
||||||
## Port Mapping
|
## Port Mapping
|
||||||
|
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,15 +1,20 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
# Playwright's Chromium needs system libraries that Alpine does not provide
|
||||||
|
# reliably for this app's CI/runtime path. Install the Linux dependencies in
|
||||||
|
# the image so browser tests can launch in GitHub Actions and container runs.
|
||||||
|
RUN npx playwright install --with-deps chromium
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run prisma:generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["sh", "-c", "npm run prisma:migrate:deploy && npm run prisma:seed && node .next/standalone/server.js"]
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ Phase 1 scaffold started from the approved planning docs, and the Vikunja board
|
|||||||
- The first build tickets are queued on the board, with later phase work staged behind them
|
- The first build tickets are queued on the board, with later phase work staged behind them
|
||||||
- Post-dev flow is documented so implementation tickets always merge to `develop`, then hand off into test/validation before QA promotion
|
- Post-dev flow is documented so implementation tickets always merge to `develop`, then hand off into test/validation before QA promotion
|
||||||
- New functionality should extend the Playwright suite so browser regression coverage grows with the app
|
- New functionality should extend the Playwright suite so browser regression coverage grows with the app
|
||||||
|
- Operating cadence is documented so Neo takes the top `Ready for Dev` ticket every hour, posts a completion summary to Discord when done, Morpheus reviews the lanes every hour, and the queue is refilled before it drops below 5 unworked tickets
|
||||||
|
|
||||||
## Next Build Step
|
## Next Build Step
|
||||||
|
|
||||||
- Start with `VIK-108`, then work through `VIK-109` to `VIK-112` in order before pulling from the backlog queue.
|
- Start with `VIK-108`, then work through `VIK-109` to `VIK-112` in order before pulling from the backlog queue.
|
||||||
|
- Once fewer than 5 tickets remain unworked, create the next batch from the next unresolved phase instead of letting the queue drain.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The project has been onboarded to Vikunja and the board now uses the playbook la
|
|||||||
The next public work is queued as tickets, with `VIK-112` staged as the next active item after the current slice.
|
The next public work is queued as tickets, with `VIK-112` staged as the next active item after the current slice.
|
||||||
Implementation tickets are expected to merge back to `develop` first, then hand off into `Ready for Test` / `Deploying to Dev` before Trinity validation and later QA promotion.
|
Implementation tickets are expected to merge back to `develop` first, then hand off into `Ready for Test` / `Deploying to Dev` before Trinity validation and later QA promotion.
|
||||||
Any new feature work should also update the Playwright suite so the browser tests become the main regression check as coverage expands.
|
Any new feature work should also update the Playwright suite so the browser tests become the main regression check as coverage expands.
|
||||||
|
Operating cadence is now defined in `09-operating-cadence-and-batch-plan.md`: Neo pulls the top `Ready for Dev` ticket every hour, posts a completion summary to Discord when finished, Morpheus reviews the lanes every hour, and the ready queue is replenished when fewer than 5 tickets remain unworked.
|
||||||
|
|
||||||
## Working Rule
|
## Working Rule
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ We will not build this in one shot. Each numbered document in this folder define
|
|||||||
6. `06-admin-console.md`
|
6. `06-admin-console.md`
|
||||||
7. `07-seo-accessibility-performance.md`
|
7. `07-seo-accessibility-performance.md`
|
||||||
8. `08-implementation-plan-and-launch-readiness.md`
|
8. `08-implementation-plan-and-launch-readiness.md`
|
||||||
|
9. `09-operating-cadence-and-batch-plan.md`
|
||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ This board is normalized to the shared playbook lane model.
|
|||||||
- Feature work now lives on this board and should be tracked as separate tickets.
|
- Feature work now lives on this board and should be tracked as separate tickets.
|
||||||
- Use the playbook lane names exactly when routing work.
|
- Use the playbook lane names exactly when routing work.
|
||||||
- When a ticket finishes `In Dev`, Neo merges the feature branch back to `develop`, leaves a merge-complete comment, and hands the ticket forward for validation rather than marking it done.
|
- When a ticket finishes `In Dev`, Neo merges the feature branch back to `develop`, leaves a merge-complete comment, and hands the ticket forward for validation rather than marking it done.
|
||||||
|
- When Morpheus routes work, send Neo the handoff directly and leave the ticket comment as a concise record of the lane change and next action; do not rely on the bucket comment as the only delivery path.
|
||||||
- After the merge, move the ticket into `Ready for Test` and then `Deploying to Dev` while the dev build/deploy proves the change.
|
- After the merge, move the ticket into `Ready for Test` and then `Deploying to Dev` while the dev build/deploy proves the change.
|
||||||
- Trinity handles validation once the dev environment is ready, and the ticket only moves forward after the live check passes.
|
- Trinity handles validation once the dev environment is ready, and the ticket only moves forward after the live check passes.
|
||||||
- QA promotion is a separate step after dev validation, not part of the merge itself.
|
- QA promotion is a separate step after dev validation, not part of the merge itself.
|
||||||
|
|||||||
2228
package-lock.json
generated
2228
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -5,10 +5,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "node .next/standalone/server.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
"prisma:migrate:dev": "prisma migrate dev",
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
"prisma:seed": "tsx prisma/seed.ts"
|
"prisma:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
@@ -22,14 +24,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -573,6 +573,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;
|
||||||
@@ -719,6 +792,7 @@ 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,
|
||||||
@@ -761,6 +835,7 @@ main {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.policy-grid,
|
||||||
.property-metrics {
|
.property-metrics {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ export default function HomePage() {
|
|||||||
<div className="property-card-top">
|
<div className="property-card-top">
|
||||||
<div>
|
<div>
|
||||||
<p className="footer-label">{property.area}</p>
|
<p className="footer-label">{property.area}</p>
|
||||||
<h3>{property.name}</h3>
|
<h3>
|
||||||
|
<Link href={`/properties/${property.slug}`}>{property.name}</Link>
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="property-price">{property.priceFrom}</span>
|
<span className="property-price">{property.priceFrom}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +155,9 @@ export default function HomePage() {
|
|||||||
<li key={tag}>{tag}</li>
|
<li key={tag}>{tag}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<Link className="inline-link" href={`/properties/${property.slug}`}>
|
||||||
|
View property details
|
||||||
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
296
src/app/properties/[slug]/page.tsx
Normal file
296
src/app/properties/[slug]/page.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Section } from '@/components/Section';
|
||||||
|
import { getPublishedPropertyBySlug } from '@/lib/properties';
|
||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
import { site } from '@/lib/site';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
type PropertyPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPounds(cents: number) {
|
||||||
|
return new Intl.NumberFormat('en-GB', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'GBP',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReason(reason: string) {
|
||||||
|
return reason
|
||||||
|
.toLowerCase()
|
||||||
|
.split('_')
|
||||||
|
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackMetadata(slug: string) {
|
||||||
|
const seeded = buildFallbackProperty(slug);
|
||||||
|
if (!seeded) {
|
||||||
|
return {
|
||||||
|
title: site.name,
|
||||||
|
description: site.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${seeded.title} | ${site.name}`,
|
||||||
|
description: seeded.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackProperty(slug: string) {
|
||||||
|
const seeded = propertySeedData.find((property) => property.slug === slug);
|
||||||
|
if (!seeded) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: seeded.slug,
|
||||||
|
slug: seeded.slug,
|
||||||
|
title: seeded.title,
|
||||||
|
summary: seeded.summary,
|
||||||
|
longDescription: seeded.longDescription,
|
||||||
|
locationText: seeded.locationText,
|
||||||
|
sleeps: seeded.sleeps,
|
||||||
|
bedrooms: seeded.bedrooms,
|
||||||
|
bathrooms: seeded.bathrooms,
|
||||||
|
petsAllowed: seeded.petsAllowed,
|
||||||
|
published: true,
|
||||||
|
featured: seeded.featured,
|
||||||
|
minStayNights: seeded.minStayNights,
|
||||||
|
checkInTime: seeded.checkInTime,
|
||||||
|
checkOutTime: seeded.checkOutTime,
|
||||||
|
images: seeded.images.map((image, index) => ({
|
||||||
|
id: `${seeded.slug}-image-${index}`,
|
||||||
|
url: image.url,
|
||||||
|
altText: image.altText,
|
||||||
|
primaryImage: image.primaryImage ?? index === 0,
|
||||||
|
})),
|
||||||
|
amenities: seeded.amenities.map((amenity, index) => ({
|
||||||
|
amenityId: `${seeded.slug}-amenity-${index}`,
|
||||||
|
amenity: {
|
||||||
|
name: amenity,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
pricingRules: seeded.pricingRules.map((rule, index) => ({
|
||||||
|
id: `${seeded.slug}-pricing-${index}`,
|
||||||
|
label: rule.label || null,
|
||||||
|
basePriceCents: rule.basePriceCents,
|
||||||
|
weekendPriceCents: rule.weekendPriceCents ?? null,
|
||||||
|
guestDeltaCents: rule.guestDeltaCents ?? null,
|
||||||
|
validFrom: rule.validFrom ? new Date(`${rule.validFrom}T00:00:00.000Z`) : null,
|
||||||
|
validTo: rule.validTo ? new Date(`${rule.validTo}T00:00:00.000Z`) : null,
|
||||||
|
})),
|
||||||
|
availability: seeded.availabilityBlocks.map((block, index) => ({
|
||||||
|
id: `${seeded.slug}-availability-${index}`,
|
||||||
|
startDate: new Date(`${block.startDate}T00:00:00.000Z`),
|
||||||
|
endDate: new Date(`${block.endDate}T00:00:00.000Z`),
|
||||||
|
reason: block.reason,
|
||||||
|
notes: block.notes ?? null,
|
||||||
|
})),
|
||||||
|
testimonials: seeded.testimonials.map((testimonial, index) => ({
|
||||||
|
id: `${seeded.slug}-testimonial-${index}`,
|
||||||
|
authorName: testimonial.authorName,
|
||||||
|
content: testimonial.content,
|
||||||
|
rating: testimonial.rating ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PropertyPageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const property = await getPublishedPropertyBySlug(slug);
|
||||||
|
if (!property) {
|
||||||
|
return fallbackMetadata(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${property.title} | ${site.name}`,
|
||||||
|
description: property.summary,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return fallbackMetadata(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PropertyDetailPage({ params }: PropertyPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const property = (await getPublishedPropertyBySlug(slug)) ?? buildFallbackProperty(slug);
|
||||||
|
if (!property) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryImage = property.images[0] ?? null;
|
||||||
|
const standardRate = property.pricingRules.find((rule) => !rule.validFrom && !rule.validTo) ?? property.pricingRules[0];
|
||||||
|
const seasonalRates = property.pricingRules.filter((rule) => rule.validFrom && rule.validTo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero property-hero">
|
||||||
|
<div>
|
||||||
|
<p className="brand-kicker">Property detail</p>
|
||||||
|
<h2>{property.title}</h2>
|
||||||
|
<p>{property.summary}</p>
|
||||||
|
<div className="hero-actions">
|
||||||
|
<Link className="btn btn-primary" href={`/bookings/new?propertySlug=${property.slug}`}>
|
||||||
|
Check availability
|
||||||
|
</Link>
|
||||||
|
<Link className="btn btn-outline-dark" href={`/contact?property=${encodeURIComponent(property.title)}`}>
|
||||||
|
Ask a question first
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article className="property-callout">
|
||||||
|
<p className="footer-label">Booking context</p>
|
||||||
|
<strong>{standardRate ? `From ${formatPounds(standardRate.basePriceCents)} per night` : 'Price on request'}</strong>
|
||||||
|
<p>{property.locationText}</p>
|
||||||
|
<dl className="property-metrics">
|
||||||
|
<div>
|
||||||
|
<dt>Sleeps</dt>
|
||||||
|
<dd>{property.sleeps}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bedrooms</dt>
|
||||||
|
<dd>{property.bedrooms}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bathrooms</dt>
|
||||||
|
<dd>{property.bathrooms}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="page-layout property-layout">
|
||||||
|
<Section
|
||||||
|
eyebrow="Gallery and overview"
|
||||||
|
title="Everything a guest needs before starting the booking flow"
|
||||||
|
description="This page is intentionally practical first: imagery, essentials, stay rules, and a clear route into booking or enquiry."
|
||||||
|
>
|
||||||
|
<div className="property-gallery" aria-label={`${property.title} gallery`}>
|
||||||
|
{property.images.map((image) => (
|
||||||
|
<figure key={image.id} className={`property-gallery-card ${image.primaryImage ? 'is-primary' : ''}`}>
|
||||||
|
<img src={image.url} alt={image.altText} />
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-stack">
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>About this stay</h3>
|
||||||
|
<p>{property.longDescription}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Amenities</h3>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{property.amenities.map((item) => (
|
||||||
|
<li key={item.amenityId}>{item.amenity.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Stay policies and practical details</h3>
|
||||||
|
<div className="policy-grid">
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.minStayNights}</strong>
|
||||||
|
<span>Minimum nights</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.checkInTime ?? '16:00'}</strong>
|
||||||
|
<span>Check-in</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.checkOutTime ?? '10:00'}</strong>
|
||||||
|
<span>Check-out</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<strong>{property.petsAllowed ? 'Allowed' : 'Not allowed'}</strong>
|
||||||
|
<span>Pets</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<aside className="content-sidebar">
|
||||||
|
<article className="content-card">
|
||||||
|
<p className="footer-label">Primary image</p>
|
||||||
|
<h3>{primaryImage ? primaryImage.altText : 'Property overview'}</h3>
|
||||||
|
<p className="mb-0">
|
||||||
|
{primaryImage ? 'The first gallery image sets the main expectation for the stay.' : property.summary}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Availability and pricing context</h3>
|
||||||
|
<ul className="admin-bullet-list">
|
||||||
|
<li>{standardRate ? `Base nightly rate: ${formatPounds(standardRate.basePriceCents)}` : 'Rate available on request'}</li>
|
||||||
|
<li>{standardRate?.weekendPriceCents ? `Weekend rate: ${formatPounds(standardRate.weekendPriceCents)}` : 'Weekend pricing follows the base rate'}</li>
|
||||||
|
<li>{standardRate?.guestDeltaCents ? `Additional guest supplement: ${formatPounds(standardRate.guestDeltaCents)} per night` : 'No guest supplement applies'}</li>
|
||||||
|
</ul>
|
||||||
|
{seasonalRates.length ? (
|
||||||
|
<div className="availability-list">
|
||||||
|
{seasonalRates.map((rule) => (
|
||||||
|
<div key={rule.id} className="availability-item">
|
||||||
|
<strong>{rule.label || 'Seasonal rate'}</strong>
|
||||||
|
<span>
|
||||||
|
{formatDate(rule.validFrom as Date)} to {formatDate(rule.validTo as Date)} · {formatPounds(rule.basePriceCents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Known unavailable dates</h3>
|
||||||
|
<div className="availability-list">
|
||||||
|
{property.availability.length ? (
|
||||||
|
property.availability.map((block) => (
|
||||||
|
<div key={block.id} className="availability-item">
|
||||||
|
<strong>{formatReason(block.reason)}</strong>
|
||||||
|
<span>
|
||||||
|
{formatDate(block.startDate)} to {formatDate(block.endDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="mb-0">No blocked dates are currently published for this property.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="content-card">
|
||||||
|
<h3>Guest feedback</h3>
|
||||||
|
<div className="content-stack">
|
||||||
|
{property.testimonials.map((testimonial) => (
|
||||||
|
<blockquote key={testimonial.id} className="testimonial-card">
|
||||||
|
<p>{testimonial.content}</p>
|
||||||
|
<footer>
|
||||||
|
<strong>{testimonial.authorName}</strong>
|
||||||
|
<span>{testimonial.rating ? `${testimonial.rating}/5 rating` : 'Published guest feedback'}</span>
|
||||||
|
</footer>
|
||||||
|
</blockquote>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
|
||||||
export type BookingSearchInput = {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
179
src/lib/properties.ts
Normal file
179
src/lib/properties.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
|
||||||
|
const propertyInclude = {
|
||||||
|
amenities: {
|
||||||
|
include: {
|
||||||
|
amenity: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
orderBy: [{ primaryImage: 'desc' }, { displayOrder: 'asc' }],
|
||||||
|
},
|
||||||
|
pricingRules: {
|
||||||
|
orderBy: [{ validFrom: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
},
|
||||||
|
availability: {
|
||||||
|
orderBy: { startDate: 'asc' },
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { displayOrder: 'asc' },
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PropertyInclude;
|
||||||
|
|
||||||
|
export type PropertyDetailRecord = Prisma.PropertyGetPayload<{
|
||||||
|
include: typeof propertyInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function slugifyAmenity(name: string) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedPropertyInventory() {
|
||||||
|
const amenityIdByName = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const amenityName of new Set(propertySeedData.flatMap((property) => property.amenities))) {
|
||||||
|
const amenity = await prisma.amenity.upsert({
|
||||||
|
where: { slug: slugifyAmenity(amenityName) },
|
||||||
|
create: {
|
||||||
|
slug: slugifyAmenity(amenityName),
|
||||||
|
name: amenityName,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: amenityName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
amenityIdByName.set(amenityName, amenity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const property of propertySeedData) {
|
||||||
|
const record = await prisma.property.upsert({
|
||||||
|
where: { slug: property.slug },
|
||||||
|
create: {
|
||||||
|
slug: property.slug,
|
||||||
|
title: property.title,
|
||||||
|
summary: property.summary,
|
||||||
|
longDescription: property.longDescription,
|
||||||
|
locationText: property.locationText,
|
||||||
|
sleeps: property.sleeps,
|
||||||
|
bedrooms: property.bedrooms,
|
||||||
|
bathrooms: property.bathrooms,
|
||||||
|
petsAllowed: property.petsAllowed,
|
||||||
|
published: true,
|
||||||
|
featured: property.featured,
|
||||||
|
minStayNights: property.minStayNights,
|
||||||
|
checkInTime: property.checkInTime,
|
||||||
|
checkOutTime: property.checkOutTime,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: property.title,
|
||||||
|
summary: property.summary,
|
||||||
|
longDescription: property.longDescription,
|
||||||
|
locationText: property.locationText,
|
||||||
|
sleeps: property.sleeps,
|
||||||
|
bedrooms: property.bedrooms,
|
||||||
|
bathrooms: property.bathrooms,
|
||||||
|
petsAllowed: property.petsAllowed,
|
||||||
|
published: true,
|
||||||
|
featured: property.featured,
|
||||||
|
minStayNights: property.minStayNights,
|
||||||
|
checkInTime: property.checkInTime,
|
||||||
|
checkOutTime: property.checkOutTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.propertyAmenity.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.propertyImage.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.pricingRule.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.availabilityBlock.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
await prisma.testimonial.deleteMany({ where: { propertyId: record.id } });
|
||||||
|
|
||||||
|
await prisma.propertyAmenity.createMany({
|
||||||
|
data: property.amenities.map((amenityName) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
amenityId: amenityIdByName.get(amenityName) || '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.propertyImage.createMany({
|
||||||
|
data: property.images.map((image, imageIndex) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
url: image.url,
|
||||||
|
altText: image.altText,
|
||||||
|
displayOrder: imageIndex,
|
||||||
|
primaryImage: image.primaryImage ?? imageIndex === 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.pricingRule.createMany({
|
||||||
|
data: property.pricingRules.map((rule) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
label: rule.label || null,
|
||||||
|
basePriceCents: rule.basePriceCents,
|
||||||
|
weekendPriceCents: rule.weekendPriceCents ?? null,
|
||||||
|
guestDeltaCents: rule.guestDeltaCents ?? null,
|
||||||
|
validFrom: rule.validFrom ? new Date(`${rule.validFrom}T00:00:00.000Z`) : null,
|
||||||
|
validTo: rule.validTo ? new Date(`${rule.validTo}T00:00:00.000Z`) : null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.availabilityBlock.createMany({
|
||||||
|
data: property.availabilityBlocks.map((block) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
startDate: new Date(`${block.startDate}T00:00:00.000Z`),
|
||||||
|
endDate: new Date(`${block.endDate}T00:00:00.000Z`),
|
||||||
|
reason: block.reason,
|
||||||
|
notes: block.notes ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.testimonial.createMany({
|
||||||
|
data: property.testimonials.map((testimonial, testimonialIndex) => ({
|
||||||
|
propertyId: record.id,
|
||||||
|
authorName: testimonial.authorName,
|
||||||
|
content: testimonial.content,
|
||||||
|
rating: testimonial.rating ?? null,
|
||||||
|
published: true,
|
||||||
|
displayOrder: testimonialIndex,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSeededProperties() {
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seededCount = await prisma.property.count({
|
||||||
|
where: {
|
||||||
|
slug: {
|
||||||
|
in: propertySeedData.map((property) => property.slug),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const imageCount = await prisma.propertyImage.count();
|
||||||
|
|
||||||
|
if (seededCount === propertySeedData.length && imageCount > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await seedPropertyInventory();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublishedPropertyBySlug(slug: string) {
|
||||||
|
const hasDatabase = await ensureSeededProperties();
|
||||||
|
if (!hasDatabase) return null;
|
||||||
|
|
||||||
|
return prisma.property.findFirst({
|
||||||
|
where: { slug, published: true },
|
||||||
|
include: propertyInclude,
|
||||||
|
});
|
||||||
|
}
|
||||||
258
src/lib/propertySeedData.ts
Normal file
258
src/lib/propertySeedData.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
type SeedImage = {
|
||||||
|
url: string;
|
||||||
|
altText: string;
|
||||||
|
primaryImage?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedPricingRule = {
|
||||||
|
label?: string;
|
||||||
|
basePriceCents: number;
|
||||||
|
weekendPriceCents?: number;
|
||||||
|
guestDeltaCents?: number;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedAvailabilityBlock = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
reason: 'MAINTENANCE' | 'OWNER_BLOCKED' | 'BASE_RULE' | 'OTHER';
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedTestimonial = {
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
rating?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeedProperty = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
area: string;
|
||||||
|
summary: string;
|
||||||
|
longDescription: string;
|
||||||
|
locationText: string;
|
||||||
|
sleeps: number;
|
||||||
|
bedrooms: number;
|
||||||
|
bathrooms: number;
|
||||||
|
petsAllowed: boolean;
|
||||||
|
featured: boolean;
|
||||||
|
minStayNights: number;
|
||||||
|
checkInTime: string;
|
||||||
|
checkOutTime: string;
|
||||||
|
marketingTags: string[];
|
||||||
|
images: SeedImage[];
|
||||||
|
amenities: string[];
|
||||||
|
pricingRules: SeedPricingRule[];
|
||||||
|
availabilityBlocks: SeedAvailabilityBlock[];
|
||||||
|
testimonials: SeedTestimonial[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const propertySeedData: SeedProperty[] = [
|
||||||
|
{
|
||||||
|
slug: 'coastal-view-cottage',
|
||||||
|
title: 'Coastal View Cottage',
|
||||||
|
area: 'Clifftop village',
|
||||||
|
summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.',
|
||||||
|
longDescription:
|
||||||
|
'Coastal View Cottage is set above the bay with a sunny terrace, two calm bedrooms, and a living space designed for slower mornings after coastal walks. The stay is positioned for guests who want practical comfort first, then the sea view to do the rest of the work.',
|
||||||
|
locationText:
|
||||||
|
'The cottage sits in a clifftop village a short walk from the harbour path, local bakery, and a small beach reached by steps down the headland.',
|
||||||
|
sleeps: 4,
|
||||||
|
bedrooms: 2,
|
||||||
|
bathrooms: 2,
|
||||||
|
petsAllowed: false,
|
||||||
|
featured: true,
|
||||||
|
minStayNights: 2,
|
||||||
|
checkInTime: '16:00',
|
||||||
|
checkOutTime: '10:00',
|
||||||
|
marketingTags: ['Sea views', 'Family-friendly', 'Short breaks'],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1600&q=80',
|
||||||
|
altText: 'Sunlit sitting room with a wide sea-facing window and light wood furniture.',
|
||||||
|
primaryImage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
altText: 'Terrace seating overlooking the coastline in late afternoon light.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=900&q=80',
|
||||||
|
altText: 'Bedroom with neutral linen and coastal tones.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
amenities: ['Sea view terrace', 'Fast Wi-Fi', 'Family dining kitchen', 'Parking for one car', 'Walk-in shower'],
|
||||||
|
pricingRules: [
|
||||||
|
{
|
||||||
|
label: 'Standard season',
|
||||||
|
basePriceCents: 18500,
|
||||||
|
weekendPriceCents: 21500,
|
||||||
|
guestDeltaCents: 1800,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Summer high season',
|
||||||
|
basePriceCents: 22500,
|
||||||
|
weekendPriceCents: 25500,
|
||||||
|
guestDeltaCents: 1800,
|
||||||
|
validFrom: '2026-06-01',
|
||||||
|
validTo: '2026-09-30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availabilityBlocks: [
|
||||||
|
{
|
||||||
|
startDate: '2026-03-15',
|
||||||
|
endDate: '2026-03-18',
|
||||||
|
reason: 'MAINTENANCE',
|
||||||
|
notes: 'Spring maintenance window',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: '2026-08-18',
|
||||||
|
endDate: '2026-08-25',
|
||||||
|
reason: 'OWNER_BLOCKED',
|
||||||
|
notes: 'Owner stay',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
testimonials: [
|
||||||
|
{
|
||||||
|
authorName: 'Sophie M.',
|
||||||
|
content: 'We could tell exactly what the stay would feel like before we booked, and the sea view more than held up.',
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'orchard-barn',
|
||||||
|
title: 'Orchard Barn',
|
||||||
|
area: 'Rural retreat',
|
||||||
|
summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.',
|
||||||
|
longDescription:
|
||||||
|
'Orchard Barn gives groups more room to spread out without losing the warmth of a rural stay. The main space stays open and sociable, while the bedrooms and garden edges keep it comfortable for longer weekends and family trips.',
|
||||||
|
locationText:
|
||||||
|
'Set back from a quiet lane beside orchards and footpaths, the barn is well placed for walking routes, local pubs, and slower countryside stays.',
|
||||||
|
sleeps: 6,
|
||||||
|
bedrooms: 3,
|
||||||
|
bathrooms: 2,
|
||||||
|
petsAllowed: true,
|
||||||
|
featured: true,
|
||||||
|
minStayNights: 3,
|
||||||
|
checkInTime: '15:00',
|
||||||
|
checkOutTime: '10:30',
|
||||||
|
marketingTags: ['Pets considered', 'Hot tub', 'Long weekends'],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1500&q=80',
|
||||||
|
altText: 'Converted barn living area with high ceilings and exposed timber beams.',
|
||||||
|
primaryImage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1100&q=80',
|
||||||
|
altText: 'Outdoor dining and garden edge beside the orchard.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=950&q=80',
|
||||||
|
altText: 'Bedroom with vaulted ceiling and soft rural palette.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
amenities: ['Hot tub', 'Dog-friendly utility area', 'Large dining table', 'Wood burner', 'Private garden'],
|
||||||
|
pricingRules: [
|
||||||
|
{
|
||||||
|
label: 'Standard season',
|
||||||
|
basePriceCents: 21000,
|
||||||
|
weekendPriceCents: 24000,
|
||||||
|
guestDeltaCents: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Harvest season',
|
||||||
|
basePriceCents: 23000,
|
||||||
|
weekendPriceCents: 26000,
|
||||||
|
guestDeltaCents: 1200,
|
||||||
|
validFrom: '2026-09-01',
|
||||||
|
validTo: '2026-10-31',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availabilityBlocks: [
|
||||||
|
{
|
||||||
|
startDate: '2026-05-12',
|
||||||
|
endDate: '2026-05-17',
|
||||||
|
reason: 'MAINTENANCE',
|
||||||
|
notes: 'Hot tub service and garden works',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
testimonials: [
|
||||||
|
{
|
||||||
|
authorName: 'Daniel K.',
|
||||||
|
content: 'The layout made group planning easy, and the practical details answered the usual pre-booking questions.',
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'harbour-house',
|
||||||
|
title: 'Harbour House',
|
||||||
|
area: 'Harbour front',
|
||||||
|
summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.',
|
||||||
|
longDescription:
|
||||||
|
'Harbour House is a walkable base for guests who want the town on the doorstep. It works well for couples who want extra room or smaller groups who care more about location and simple access than seclusion.',
|
||||||
|
locationText:
|
||||||
|
'The house fronts the harbour road with quick access to cafés, restaurants, boat trips, and an easy evening walk along the water.',
|
||||||
|
sleeps: 5,
|
||||||
|
bedrooms: 3,
|
||||||
|
bathrooms: 1,
|
||||||
|
petsAllowed: false,
|
||||||
|
featured: true,
|
||||||
|
minStayNights: 3,
|
||||||
|
checkInTime: '16:00',
|
||||||
|
checkOutTime: '10:00',
|
||||||
|
marketingTags: ['Walkable', 'Town stay', 'Flexible dates'],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1450&q=80',
|
||||||
|
altText: 'Townhouse sitting room near the harbour with layered textures and soft light.',
|
||||||
|
primaryImage: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=1080&q=80',
|
||||||
|
altText: 'Dining space with views toward the harbour street.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?auto=format&fit=crop&w=920&q=80',
|
||||||
|
altText: 'Bedroom styled for a short waterside stay.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
amenities: ['Harbour access', 'Walkable restaurants', 'Fast broadband', 'Compact workspace', 'Flexible sleeping setup'],
|
||||||
|
pricingRules: [
|
||||||
|
{
|
||||||
|
label: 'Standard season',
|
||||||
|
basePriceCents: 16500,
|
||||||
|
weekendPriceCents: 19000,
|
||||||
|
guestDeltaCents: 1500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Peak summer',
|
||||||
|
basePriceCents: 19500,
|
||||||
|
weekendPriceCents: 22500,
|
||||||
|
guestDeltaCents: 1500,
|
||||||
|
validFrom: '2026-07-01',
|
||||||
|
validTo: '2026-08-31',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availabilityBlocks: [
|
||||||
|
{
|
||||||
|
startDate: '2026-06-01',
|
||||||
|
endDate: '2026-06-05',
|
||||||
|
reason: 'OWNER_BLOCKED',
|
||||||
|
notes: 'Harbour festival owner use',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
testimonials: [
|
||||||
|
{
|
||||||
|
authorName: 'Priya R.',
|
||||||
|
content: 'Perfect for a car-light stay. We could see what was included, where it was, and how to get started without any dead ends.',
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { propertySeedData } from '@/lib/propertySeedData';
|
||||||
|
|
||||||
export type FeaturedProperty = {
|
export type FeaturedProperty = {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,39 +60,19 @@ export const primaryNavigation = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const featuredProperties: FeaturedProperty[] = [
|
export const featuredProperties: FeaturedProperty[] = [
|
||||||
{
|
...propertySeedData
|
||||||
slug: 'coastal-view-cottage',
|
.filter((property) => property.featured)
|
||||||
name: 'Coastal View Cottage',
|
.map((property) => ({
|
||||||
area: 'Clifftop village',
|
slug: property.slug,
|
||||||
summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.',
|
name: property.title,
|
||||||
sleeps: 4,
|
area: property.area,
|
||||||
bedrooms: 2,
|
summary: property.summary,
|
||||||
bathrooms: 2,
|
sleeps: property.sleeps,
|
||||||
priceFrom: 'From £185/night',
|
bedrooms: property.bedrooms,
|
||||||
tags: ['Sea views', 'Family-friendly', 'Short breaks'],
|
bathrooms: property.bathrooms,
|
||||||
},
|
priceFrom: `From £${Math.round(property.pricingRules[0]?.basePriceCents ? property.pricingRules[0].basePriceCents / 100 : 0)}/night`,
|
||||||
{
|
tags: property.marketingTags,
|
||||||
slug: 'orchard-barn',
|
})),
|
||||||
name: 'Orchard Barn',
|
|
||||||
area: 'Rural retreat',
|
|
||||||
summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.',
|
|
||||||
sleeps: 6,
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 2,
|
|
||||||
priceFrom: 'From £210/night',
|
|
||||||
tags: ['Pets considered', 'Hot tub', 'Long weekends'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'harbour-house',
|
|
||||||
name: 'Harbour House',
|
|
||||||
area: 'Harbour front',
|
|
||||||
summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.',
|
|
||||||
sleeps: 5,
|
|
||||||
bedrooms: 3,
|
|
||||||
bathrooms: 1,
|
|
||||||
priceFrom: 'From £165/night',
|
|
||||||
tags: ['Walkable', 'Town stay', 'Flexible dates'],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const locationHighlights = [
|
export const locationHighlights = [
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ test.describe('homepage', () => {
|
|||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Holiday Property Booking' })).toBeVisible();
|
||||||
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
|
await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: 'Explore featured stays' })).toHaveAttribute('href', '/#browse');
|
await expect(page.getByRole('link', { name: 'Explore featured stays' })).toHaveAttribute('href', '#browse');
|
||||||
await expect(page.getByRole('link', { name: 'Contact the team' })).toHaveAttribute('href', '/contact');
|
await expect(page.getByRole('link', { name: 'Contact the team' })).toHaveAttribute('href', '/contact');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ test.describe('homepage', () => {
|
|||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'A few properties guests can imagine themselves in' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'A few properties guests can imagine themselves in' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Editorial content keeps the journey understandable' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Editorial content keeps the journey understandable' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Location highlights' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'The site can now speak about place, not just property' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'A clear contact route is already live' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'A clear contact route is already live' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
36
tests/e2e/property-detail.spec.ts
Normal file
36
tests/e2e/property-detail.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('property detail flow', () => {
|
||||||
|
test('opens a property detail page and carries the guest into booking and enquiry entry points', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'View property details' }).first().click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/properties\/coastal-view-cottage$/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Coastal View Cottage' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Everything a guest needs before starting the booking flow' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Check availability' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/bookings/new?propertySlug=coastal-view-cottage',
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('link', { name: 'Ask a question first' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/contact?property=Coastal%20View%20Cottage',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Check availability' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/bookings\/new\?propertySlug=coastal-view-cottage$/);
|
||||||
|
await expect(page.locator('select[name="propertySlug"]')).toHaveValue('coastal-view-cottage');
|
||||||
|
|
||||||
|
await page.goto('/properties/coastal-view-cottage');
|
||||||
|
await page.getByRole('link', { name: 'Ask a question first' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/contact\?property=Coastal%20View%20Cottage$/);
|
||||||
|
await expect(page.locator('input[name="property"]')).toHaveValue('Coastal View Cottage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a 404 for an unknown property slug', async ({ page }) => {
|
||||||
|
const response = await page.goto('/properties/not-a-real-property');
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
19
vitest.config.ts
Normal file
19
vitest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
jsx: 'automatic',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
restoreMocks: true,
|
||||||
|
clearMocks: true,
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user