7 Commits

Author SHA1 Message Date
d1314f2181 Add booking checkout and webhook coverage
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 4m54s
Playwright Holiday Property Booking / playwright (push) Failing after 2m32s
Test & Build Holiday Property Booking / test-build (push) Successful in 1m44s
2026-05-26 09:08:43 +00:00
0aaba14300 test: align homepage spec with current content
All checks were successful
Deploy Holiday Property Booking / deploy (push) Successful in 1m43s
Playwright Holiday Property Booking / playwright (push) Successful in 1m26s
Test & Build Holiday Property Booking / test-build (push) Successful in 1m15s
2026-05-26 08:19:53 +00:00
e468876d59 ci: skip node setup when already present
Some checks failed
Deploy Holiday Property Booking / deploy (push) Successful in 1m43s
Playwright Holiday Property Booking / playwright (push) Failing after 1m42s
Test & Build Holiday Property Booking / test-build (push) Successful in 1m20s
2026-05-26 08:13:05 +00:00
b28426594c fix: make Playwright runtime work in docker
Some checks failed
Deploy Holiday Property Booking / deploy (push) Successful in 4m19s
Test & Build Holiday Property Booking / test-build (push) Has been cancelled
Playwright Holiday Property Booking / playwright (push) Has been cancelled
2026-05-26 08:03:14 +00:00
7b6d2d8603 fix: generate prisma client during docker build
Some checks failed
Playwright Holiday Property Booking / playwright (push) Failing after 6m26s
Deploy Holiday Property Booking / deploy (push) Successful in 3m13s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m42s
2026-05-25 13:38:15 +00:00
7b9ae307a5 docs: add operating cadence and clean styling
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 30s
Playwright Holiday Property Booking / playwright (push) Failing after 6m57s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m43s
2026-05-25 13:16:57 +00:00
ea7ae9087e Merge branch 'feature/vik-114-payments-checkout' into develop
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 54s
Playwright Holiday Property Booking / playwright (push) Failing after 6m59s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m50s
2026-05-24 23:56:00 +00:00
19 changed files with 2877 additions and 558 deletions

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2228
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,8 @@ export default async function BookingPage({ params, searchParams }: BookingPageP
}
const paymentStatus = booking.payment?.status ?? 'REQUIRES_PAYMENT';
const paymentCompleted = paymentStatus === 'COMPLETED';
const paymentFailed = paymentStatus === 'FAILED' || booking.status === 'FAILED' || query.checkout === 'cancelled';
return (
<>
@@ -75,7 +77,16 @@ export default async function BookingPage({ params, searchParams }: BookingPageP
</article>
) : 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' }}>
<h3>Development fallback</h3>
<p>

View File

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

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

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

View File

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

19
vitest.config.ts Normal file
View File

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