diff --git a/package-lock.json b/package-lock.json index 0a951d3..4807c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "bootstrap": "^5.3.3", "next": "^15.0.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "stripe": "^17.7.0" }, "devDependencies": { "@playwright/test": "^1.60.0", @@ -1876,7 +1877,6 @@ "version": "20.17.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -2885,7 +2885,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2899,7 +2898,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3243,7 +3241,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3355,7 +3352,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3365,7 +3361,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3403,7 +3398,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4111,7 +4105,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4162,7 +4155,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4187,7 +4179,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4293,7 +4284,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4358,7 +4348,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4387,7 +4376,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5053,7 +5041,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5288,7 +5275,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5716,6 +5702,21 @@ ], "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6159,7 +6160,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6179,7 +6179,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6196,7 +6195,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6215,7 +6213,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6397,6 +6394,19 @@ "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": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6696,7 +6706,6 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index b9a6855..a5a336b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "bootstrap": "^5.3.3", "next": "^15.0.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "stripe": "^17.7.0" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/src/app/api/bookings/[bookingId]/simulate-success/route.ts b/src/app/api/bookings/[bookingId]/simulate-success/route.ts new file mode 100644 index 0000000..b5ee85b --- /dev/null +++ b/src/app/api/bookings/[bookingId]/simulate-success/route.ts @@ -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); +} diff --git a/src/app/api/bookings/checkout/route.ts b/src/app/api/bookings/checkout/route.ts new file mode 100644 index 0000000..46a356a --- /dev/null +++ b/src/app/api/bookings/checkout/route.ts @@ -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; + + 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 }); + } +} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..4eb6a50 --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -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 }); + } +} diff --git a/src/app/bookings/[bookingId]/checkout/page.tsx b/src/app/bookings/[bookingId]/checkout/page.tsx new file mode 100644 index 0000000..b1866fb --- /dev/null +++ b/src/app/bookings/[bookingId]/checkout/page.tsx @@ -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 { + 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 ( + <> +
+

Checkout

+

Finish payment for {booking.property.title}

+

+ Review the quote, then continue to Stripe if the session is configured or use the local simulation path in + development. +

+
+ +
+
+
+
+

Stay

+

{booking.property.title}

+

+ {booking.arrivalDate.toISOString().slice(0, 10)} to {booking.departureDate.toISOString().slice(0, 10)} +

+
+
+

Total

+

{formatPoundsFromCents(booking.totalCents)}

+

Current payment state: {booking.payment?.status ?? 'REQUIRES_PAYMENT'}

+
+
+ +
+

What happens next

+
    +
  • Stripe Checkout collects payment when keys are configured.
  • +
  • The webhook finalises the payment and booking state.
  • +
  • Email notifications are composed from the payment outcome.
  • +
+
+ +
+

Development fallback

+

+ 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. +

+ + Open booking status + +
+
+ + +
+ + ); +} diff --git a/src/app/bookings/[bookingId]/page.tsx b/src/app/bookings/[bookingId]/page.tsx new file mode 100644 index 0000000..c00e559 --- /dev/null +++ b/src/app/bookings/[bookingId]/page.tsx @@ -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 { + 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 ( + <> +
+

Booking status

+

{booking.property.title}

+

+ {booking.firstName} {booking.lastName} • {booking.arrivalDate.toISOString().slice(0, 10)} to{' '} + {booking.departureDate.toISOString().slice(0, 10)} +

+
+ +
+
+
+
+

Booking status

+

{booking.status}

+

Hold expires at {booking.holdExpiresAt?.toISOString() ?? 'not set'}

+
+
+

Payment status

+

{paymentStatus}

+

Total {formatPoundsFromCents(booking.totalCents)}

+
+
+ + {query.checkout === 'success' ? ( +
+

Return from checkout

+

+ The checkout return says success, but the booking is only final once the payment record shows a completed webhook event. +

+
+ ) : null} + + {booking.payment?.status !== 'COMPLETED' ? ( +
+

Development fallback

+

+ If Stripe checkout is not configured in this environment, use the local simulation button to finish + the booking and trigger the notification path. +

+
+ +
+
+ ) : ( +
+

Payment verified

+

+ The webhook has confirmed payment and the booking is now safe to display as confirmed. +

+
+ )} +
+ + +
+ + ); +} diff --git a/src/app/bookings/new/page.tsx b/src/app/bookings/new/page.tsx new file mode 100644 index 0000000..40409ef --- /dev/null +++ b/src/app/bookings/new/page.tsx @@ -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 ( + <> +
+

Booking

+

Check availability and start the booking flow

+

+ 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. +

+
+ +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +