6 Commits

Author SHA1 Message Date
090741e6ad Add booking checkout and webhook flow 2026-05-24 23:55:43 +00:00
dbdca5c023 Merge branch 'feature/vik-115-admin-console' into develop 2026-05-24 23:54:13 +00:00
ef5e36b63d Add admin console planning surface 2026-05-24 23:53:59 +00:00
42c4482341 Merge branch 'feature/vik-113-booking-core' into develop
Some checks failed
Deploy Holiday Property Booking / deploy (push) Successful in 1m37s
Playwright Holiday Property Booking / playwright (push) Failing after 17m26s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m41s
2026-05-24 23:20:53 +00:00
6118b9fd91 Implement booking availability and pricing core 2026-05-24 23:20:31 +00:00
014307f2ec Merge branch 'feature/vik-112-public-home-contact-content' into develop
Some checks failed
Deploy Holiday Property Booking / deploy (push) Failing after 2h43m39s
Playwright Holiday Property Booking / playwright (push) Failing after 7m2s
Test & Build Holiday Property Booking / test-build (push) Successful in 10m44s
2026-05-22 15:15:06 +00:00
16 changed files with 2260 additions and 30 deletions

View File

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

51
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"next": "^15.0.0", "next": "^15.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"stripe": "^17.7.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
@@ -1876,7 +1877,6 @@
"version": "20.17.6", "version": "20.17.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz",
"integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
@@ -2885,7 +2885,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -2899,7 +2898,6 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -3243,7 +3241,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
@@ -3355,7 +3352,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3365,7 +3361,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3403,7 +3398,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -4111,7 +4105,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -4162,7 +4155,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -4187,7 +4179,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
@@ -4293,7 +4284,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4358,7 +4348,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4387,7 +4376,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -5053,7 +5041,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5288,7 +5275,6 @@
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5716,6 +5702,21 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6159,7 +6160,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6179,7 +6179,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6196,7 +6195,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -6215,7 +6213,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -6397,6 +6394,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/stripe": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
"integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/styled-jsx": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6696,7 +6706,6 @@
"version": "6.19.8", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {

View File

@@ -17,7 +17,8 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"next": "^15.0.0", "next": "^15.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"stripe": "^17.7.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,344 @@
export type BookingSearchInput = {
arrivalDate?: string;
departureDate?: string;
adults: number;
children: number;
pets: number;
location?: string;
propertySlug?: string;
};
type DateRange = {
startDate: string;
endDate: string;
reason: string;
};
type SeasonalRate = {
label: string;
startDate: string;
endDate: string;
nightlyCents: number;
weekendNightlyCents?: number;
};
export type BookingPropertyProfile = {
slug: string;
name: string;
area: string;
summary: string;
sleeps: number;
bedrooms: number;
bathrooms: number;
published: boolean;
petsAllowed: boolean;
minStayNights: number;
baseNightlyCents: number;
weekendNightlyCents?: number;
guestSupplementCents?: number;
seasonalRates: SeasonalRate[];
availabilityBlocks: DateRange[];
confirmedBookings: DateRange[];
};
export type BookingQuote = {
propertySlug: string;
propertyName: string;
area: string;
available: boolean;
nights: number;
arrivalDate?: string;
departureDate?: string;
holdExpiresAt?: string;
reasons: string[];
nightlyRates: Array<{ date: string; label: string; amountCents: number }>;
priceBreakdown: Array<{ label: string; amountCents: number }>;
totalCents: number;
};
export type BookingSearchResult = BookingQuote & {
sleeps: number;
bedrooms: number;
bathrooms: number;
petsAllowed: boolean;
};
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const BOOKING_HOLD_MINUTES = 30;
const INCLUDED_GUESTS = 2;
export const bookingCatalog: BookingPropertyProfile[] = [
{
slug: 'coastal-view-cottage',
name: 'Coastal View Cottage',
area: 'Clifftop village',
summary: 'A bright two-bedroom cottage with sea views, a private terrace, and room to slow down.',
sleeps: 4,
bedrooms: 2,
bathrooms: 2,
published: true,
petsAllowed: false,
minStayNights: 2,
baseNightlyCents: 18500,
weekendNightlyCents: 21500,
guestSupplementCents: 1800,
seasonalRates: [
{
label: 'Summer high season',
startDate: '2026-06-01',
endDate: '2026-09-30',
nightlyCents: 22500,
weekendNightlyCents: 25500,
},
],
availabilityBlocks: [
{ startDate: '2026-03-15', endDate: '2026-03-18', reason: 'MAINTENANCE' },
{ startDate: '2026-08-18', endDate: '2026-08-25', reason: 'OWNER_BLOCKED' },
],
confirmedBookings: [{ startDate: '2026-07-21', endDate: '2026-07-28', reason: 'CONFIRMED_BOOKING' }],
},
{
slug: 'orchard-barn',
name: 'Orchard Barn',
area: 'Rural retreat',
summary: 'Converted barn accommodation with an open-plan living space and easy access to walking trails.',
sleeps: 6,
bedrooms: 3,
bathrooms: 2,
published: true,
petsAllowed: true,
minStayNights: 3,
baseNightlyCents: 21000,
weekendNightlyCents: 24000,
guestSupplementCents: 1200,
seasonalRates: [
{
label: 'Harvest season',
startDate: '2026-09-01',
endDate: '2026-10-31',
nightlyCents: 23000,
weekendNightlyCents: 26000,
},
],
availabilityBlocks: [{ startDate: '2026-05-12', endDate: '2026-05-17', reason: 'MAINTENANCE' }],
confirmedBookings: [{ startDate: '2026-06-12', endDate: '2026-06-19', reason: 'CONFIRMED_BOOKING' }],
},
{
slug: 'harbour-house',
name: 'Harbour House',
area: 'Harbour front',
summary: 'A flexible stay for couples or groups, steps from the water and close to local restaurants.',
sleeps: 5,
bedrooms: 3,
bathrooms: 1,
published: true,
petsAllowed: false,
minStayNights: 3,
baseNightlyCents: 16500,
weekendNightlyCents: 19000,
guestSupplementCents: 1500,
seasonalRates: [
{
label: 'Peak summer',
startDate: '2026-07-01',
endDate: '2026-08-31',
nightlyCents: 19500,
weekendNightlyCents: 22500,
},
],
availabilityBlocks: [{ startDate: '2026-06-01', endDate: '2026-06-05', reason: 'OWNER_BLOCKED' }],
confirmedBookings: [{ startDate: '2026-08-12', endDate: '2026-08-19', reason: 'CONFIRMED_BOOKING' }],
},
];
function parseDate(value?: string) {
if (!value) return null;
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDate(date: Date) {
return date.toISOString().slice(0, 10);
}
function addDays(date: Date, days: number) {
return new Date(date.getTime() + days * MS_PER_DAY);
}
function diffInNights(arrival: Date, departure: Date) {
return Math.round((departure.getTime() - arrival.getTime()) / MS_PER_DAY);
}
function rangesOverlap(startA: Date, endA: Date, startB: Date, endB: Date) {
return startA < endB && endA > startB;
}
function formatCurrency(cents: number) {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0,
}).format(cents / 100);
}
function getRateForDate(property: BookingPropertyProfile, night: Date) {
const seasonalRate = property.seasonalRates.find((rate) => {
const start = parseDate(rate.startDate);
const end = parseDate(rate.endDate);
return start && end ? night >= start && night <= end : false;
});
const isWeekend = night.getUTCDay() === 5 || night.getUTCDay() === 6;
if (seasonalRate) {
return {
label: seasonalRate.label,
amountCents: isWeekend && seasonalRate.weekendNightlyCents ? seasonalRate.weekendNightlyCents : seasonalRate.nightlyCents,
};
}
return {
label: isWeekend && property.weekendNightlyCents ? 'Weekend rate' : 'Base rate',
amountCents: isWeekend && property.weekendNightlyCents ? property.weekendNightlyCents : property.baseNightlyCents,
};
}
function collectConflicts(property: BookingPropertyProfile, arrival: Date, departure: Date) {
const conflicts: string[] = [];
for (const block of [...property.availabilityBlocks, ...property.confirmedBookings]) {
const start = parseDate(block.startDate);
const end = parseDate(block.endDate);
if (!start || !end) continue;
if (rangesOverlap(arrival, departure, start, end)) {
conflicts.push(
block.reason === 'CONFIRMED_BOOKING'
? `Booked from ${block.startDate} to ${block.endDate}`
: `Unavailable from ${block.startDate} to ${block.endDate}`,
);
}
}
return conflicts;
}
export function quoteStay(property: BookingPropertyProfile, input: BookingSearchInput): BookingQuote {
const reasons: string[] = [];
const arrival = parseDate(input.arrivalDate);
const departure = parseDate(input.departureDate);
const adults = Number.isFinite(input.adults) ? input.adults : 0;
const children = Number.isFinite(input.children) ? input.children : 0;
const pets = Number.isFinite(input.pets) ? input.pets : 0;
const guestCount = adults + children;
let nights = 0;
if (!arrival || !departure) {
reasons.push('Select arrival and departure dates to check availability.');
} else if (departure <= arrival) {
reasons.push('Departure must be after arrival.');
} else {
nights = diffInNights(arrival, departure);
if (nights < property.minStayNights) {
reasons.push(`Minimum stay for this property is ${property.minStayNights} nights.`);
}
}
if (guestCount > property.sleeps) {
reasons.push(`This property sleeps up to ${property.sleeps} guests.`);
}
if (pets > 0 && !property.petsAllowed) {
reasons.push('Pets are not allowed for this property.');
}
if (arrival && departure && departure > arrival) {
reasons.push(...collectConflicts(property, arrival, departure));
}
const available = reasons.length === 0;
const nightlyRates: BookingQuote['nightlyRates'] = [];
const priceBreakdown: BookingQuote['priceBreakdown'] = [];
if (available && arrival && departure) {
for (let day = 0; day < nights; day += 1) {
const night = addDays(arrival, day);
const nightlyRate = getRateForDate(property, night);
nightlyRates.push({
date: formatDate(night),
label: nightlyRate.label,
amountCents: nightlyRate.amountCents,
});
}
const accommodationCents = nightlyRates.reduce((sum, item) => sum + item.amountCents, 0);
const guestSupplementCents = Math.max(0, guestCount - INCLUDED_GUESTS) * (property.guestSupplementCents ?? 0) * nights;
priceBreakdown.push({ label: 'Accommodation', amountCents: accommodationCents });
if (guestSupplementCents > 0) {
priceBreakdown.push({ label: 'Guest supplement', amountCents: guestSupplementCents });
}
priceBreakdown.push({ label: `Hold for ${BOOKING_HOLD_MINUTES} minutes`, amountCents: 0 });
}
const totalCents = priceBreakdown.reduce((sum, item) => sum + item.amountCents, 0);
return {
propertySlug: property.slug,
propertyName: property.name,
area: property.area,
available,
nights,
arrivalDate: arrival ? formatDate(arrival) : undefined,
departureDate: departure ? formatDate(departure) : undefined,
holdExpiresAt: available ? new Date(Date.now() + BOOKING_HOLD_MINUTES * 60 * 1000).toISOString() : undefined,
reasons,
nightlyRates,
priceBreakdown,
totalCents,
};
}
export function searchBookings(input: BookingSearchInput) {
const locationQuery = input.location?.trim().toLowerCase() ?? '';
const results: BookingSearchResult[] = bookingCatalog
.filter((property) => {
if (!property.published) return false;
if (input.propertySlug && property.slug !== input.propertySlug) return false;
if (!locationQuery) return true;
const searchable = `${property.name} ${property.area} ${property.summary} ${property.slug}`.toLowerCase();
return searchable.includes(locationQuery);
})
.map((property) => ({
...quoteStay(property, input),
sleeps: property.sleeps,
bedrooms: property.bedrooms,
bathrooms: property.bathrooms,
petsAllowed: property.petsAllowed,
}))
.sort((a, b) => {
if (a.available !== b.available) return a.available ? -1 : 1;
if (a.totalCents !== b.totalCents) return a.totalCents - b.totalCents;
return a.propertyName.localeCompare(b.propertyName);
});
return {
search: {
arrivalDate: input.arrivalDate,
departureDate: input.departureDate,
adults: input.adults,
children: input.children,
pets: input.pets,
location: input.location ?? '',
},
results,
};
}
export function formatPoundsFromCents(cents: number) {
return formatCurrency(cents);
}

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

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

View File

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