diff --git a/next.config.ts b/next.config.ts index 254ed69..1283376 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,7 @@ const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "images.unsplash.com", pathname: "/**" }, + { protocol: "http", hostname: "82.112.253.199", pathname: "/**" }, { protocol: "https", hostname: "images.pexels.com", pathname: "/**" }, { protocol: "https", hostname: "cf.bstatic.com", pathname: "/**" }, ], @@ -11,5 +12,3 @@ const nextConfig: NextConfig = { }; export default nextConfig; - -import('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev()); diff --git a/package-lock.json b/package-lock.json index 01a6fb3..a0d82aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "next": "16.2.1", + "next-auth": "^4.24.11", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.8" }, "devDependencies": { "@opennextjs/aws": "^3.9.16", @@ -102,9 +104,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -122,9 +121,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -142,9 +138,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -162,9 +155,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1635,6 +1625,14 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2679,9 +2677,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2698,9 +2693,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2717,9 +2709,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2736,9 +2725,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2755,9 +2741,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2774,9 +2757,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2793,9 +2773,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2812,9 +2789,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2831,9 +2805,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2856,9 +2827,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2881,9 +2849,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2906,9 +2871,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2931,9 +2893,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2956,9 +2915,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2981,9 +2937,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3006,9 +2959,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3239,9 +3189,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3258,9 +3205,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3277,9 +3221,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3296,9 +3237,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3632,6 +3570,14 @@ "wrangler": "^4.65.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -4627,9 +4573,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4647,9 +4590,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4667,9 +4607,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4687,9 +4624,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4841,7 +4775,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -5258,9 +5192,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5275,9 +5206,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5292,9 +5220,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5309,9 +5234,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5326,9 +5248,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5343,9 +5262,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5360,9 +5276,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5377,9 +5290,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6263,7 +6173,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -8717,6 +8627,14 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8997,9 +8915,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9021,9 +8936,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9045,9 +8957,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9069,9 +8978,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9472,6 +9378,45 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -9581,6 +9526,11 @@ "node": ">=8" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9591,6 +9541,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9721,6 +9679,14 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9760,6 +9726,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10000,6 +9996,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10010,6 +10026,11 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11426,6 +11447,14 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12312,6 +12341,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 5b82198..1806093 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ }, "dependencies": { "next": "16.2.1", + "next-auth": "^4.24.11", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.8" }, "devDependencies": { "@opennextjs/aws": "^3.9.16", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..62a9268 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth-options"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/booking/BookingPageClient.tsx b/src/app/booking/BookingPageClient.tsx index 6af5230..09f558f 100644 --- a/src/app/booking/BookingPageClient.tsx +++ b/src/app/booking/BookingPageClient.tsx @@ -6,9 +6,8 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { RoomSelectBooking } from "@/components/RoomSelectBooking"; import { useBooking } from "@/context/BookingContext"; -import { rooms } from "@/lib/mocks/rooms"; -import { siteConfig } from "@/lib/mocks/site"; -import { submitBookingHold } from "@/lib/mocks/api"; +import { siteConfig } from "@/lib/site-config"; +import { createPublicBooking, ensurePropertyId } from "@/lib/public-hotel-api"; export function BookingPageClient() { const searchParams = useSearchParams(); @@ -24,6 +23,9 @@ export function BookingPageClient() { setPayLaterHold, selectedRoom, nights, + rooms, + couponCode, + setLastCreatedBooking, } = useBooking(); const [pending, setPending] = useState(null); @@ -32,33 +34,55 @@ export function BookingPageClient() { useEffect(() => { const r = searchParams.get("room"); if (r && rooms.some((x) => x.id === r)) setRoomId(r); - }, [searchParams, setRoomId]); + }, [searchParams, setRoomId, rooms]); const canContinue = selectedRoom && guest.firstName.trim() && guest.lastName.trim() && guest.email.trim() && - guest.phone.trim() && - guest.flightBookingNumber.trim() && - guest.arrivalTime.trim(); + guest.phone.trim() + // guest.flightBookingNumber.trim() && + // guest.arrivalTime.trim(); async function placeHold(mode: "payment" | "reserve") { if (!canContinue || !selectedRoom) return; setError(null); setPending(mode); try { - const { reference } = await submitBookingHold({ + const propertyId = await ensurePropertyId(); + const booking = await createPublicBooking(propertyId, { roomId: selectedRoom.id, - email: guest.email, - flightBookingNumber: guest.flightBookingNumber.trim(), + checkIn, + checkOut, + guestCount: guests, + firstName: guest.firstName.trim(), + lastName: guest.lastName.trim(), + email: guest.email.trim().toLowerCase(), + phone: guest.phone.trim(), + flightPnr: guest.flightBookingNumber.trim(), arrivalTime: guest.arrivalTime.trim(), + discountCode: couponCode.trim() || undefined, + payLaterHold: mode === "reserve", }); - setHoldReference(reference); + const code = booking.bookingCode ?? ""; + setHoldReference(code); setPayLaterHold(mode === "reserve"); + const tp = + booking.totalPrice != null + ? typeof booking.totalPrice === "string" + ? Number.parseFloat(booking.totalPrice) + : booking.totalPrice + : 0; + setLastCreatedBooking({ + id: booking.id, + bookingCode: booking.bookingCode, + totalPrice: Number.isFinite(tp) ? tp : 0, + currency: booking.currency ?? "ETB", + }); router.push(mode === "payment" ? "/payment" : "/reserve-held"); - } catch { - setError("Something went wrong. Please try again."); + } catch (e) { + setError(e instanceof Error ? e.message : "Something went wrong. Please try again."); } finally { setPending(null); } @@ -69,11 +93,9 @@ export function BookingPageClient() {

Book your stay

-

- It only takes a moment -

+

It only takes a moment

- Pay now, or reserve first and complete payment later in this session — mock only. + Live rates from the hotel. You'll receive a booking code to sign in and manage your stay.

@@ -206,8 +228,8 @@ export function BookingPageClient() { {pending === "reserve" ? "Saving your hold…" : "Reserve now — pay later"}

- Pay later keeps your details and hold reference; finish checkout from the next screen - whenever you're ready. + Pay later keeps your hold; you'll get a booking code. Payment is completed at the hotel unless + you add card checkout later.

diff --git a/src/app/confirmation/page.tsx b/src/app/confirmation/page.tsx index d9bd9a8..eb7299d 100644 --- a/src/app/confirmation/page.tsx +++ b/src/app/confirmation/page.tsx @@ -6,8 +6,9 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { useBooking } from "@/context/BookingContext"; import { useCurrency } from "@/context/CurrencyContext"; +import { formatEtb } from "@/lib/format-etb"; import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime"; -import { siteConfig } from "@/lib/mocks/site"; +import { siteConfig } from "@/lib/site-config"; export default function ConfirmationPage() { const router = useRouter(); @@ -21,6 +22,8 @@ export default function ConfirmationPage() { nights, total, resetBooking, + holdReference, + lastCreatedBooking, } = useBooking(); const { formatUsd } = useCurrency(); @@ -41,11 +44,16 @@ export default function ConfirmationPage() {

Your booking is confirmed

- Thank you, {guest.firstName}. A mock itinerary email would be sent to {guest.email}. + Thank you, {guest.firstName}. Confirmation details have been sent to {guest.email}.

Confirmation: {confirmationId}

+ {holdReference ? ( +

+ Booking code: {holdReference} +

+ ) : null} {paidAt ? (

Paid at: {new Date(paidAt).toLocaleString()} @@ -85,7 +93,12 @@ export default function ConfirmationPage() {

-

Total paid: {formatUsd(total)}

+

+ Total paid:{" "} + {lastCreatedBooking?.currency === "ETB" || selectedRoom.priceCurrency === "ETB" + ? formatEtb(lastCreatedBooking?.totalPrice ?? total) + : formatUsd(lastCreatedBooking?.totalPrice ?? total)} +

diff --git a/src/app/guest/GuestSpaGymBookingClient.tsx b/src/app/guest/GuestSpaGymBookingClient.tsx index f986825..9be1118 100644 --- a/src/app/guest/GuestSpaGymBookingClient.tsx +++ b/src/app/guest/GuestSpaGymBookingClient.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { RequireAuth } from "@/components/RequireAuth"; import { useAuth } from "@/context/AuthContext"; -import { spaGymServices, type SpaGymKind, type SpaGymService } from "@/lib/mocks/services"; +import { spaGymServices, type SpaGymKind, type SpaGymService } from "@/lib/data/services"; const MOCK_SLOTS = ["09:00", "11:00", "14:00", "16:00", "18:00"]; diff --git a/src/app/guest/laundry/LaundryClient.tsx b/src/app/guest/laundry/LaundryClient.tsx index 231d9b6..b66a626 100644 --- a/src/app/guest/laundry/LaundryClient.tsx +++ b/src/app/guest/laundry/LaundryClient.tsx @@ -1,10 +1,13 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { RequireAuth } from "@/components/RequireAuth"; import { useAuth } from "@/context/AuthContext"; -import { laundryItems } from "@/lib/mocks/laundryCatalog"; +import { guestPlaceLaundry } from "@/lib/guest-hotel-api"; +import { formatEtb } from "@/lib/format-etb"; +import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking"; +import { laundryItems, type LaundryCartItem, SAME_DAY_SURCHARGE } from "@/lib/data/laundryCatalog"; export function LaundryClient() { return ( @@ -15,155 +18,218 @@ export function LaundryClient() { } function LaundryInner() { - const { addOrder } = useAuth(); - const [qty, setQty] = useState>({}); - const [express, setExpress] = useState(false); + const { accessToken } = useAuth(); + const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking(); + + // Form states + const [cart, setCart] = useState>({}); + const [sameDay, setSameDay] = useState(false); + const [pickupAt, setPickupAt] = useState(""); + const [deliverAt, setDeliverAt] = useState(""); + const [notes, setNotes] = useState(""); const [sent, setSent] = useState(false); + const [submitErr, setSubmitErr] = useState(null); + const [submitting, setSubmitting] = useState(false); - function bump(id: string, delta: number) { - setQty((prev) => { - const next = { ...prev }; - const n = Math.max(0, (next[id] ?? 0) + delta); - if (n === 0) delete next[id]; - else next[id] = n; - return next; - }); - } + const canUseApi = !!(propertyId && accessToken && bookingId); - const lines = useMemo(() => { - const out: { id: string; name: string; count: number; unitUsd: number }[] = []; - for (const row of laundryItems) { - const q = qty[row.id]; - if (q && q > 0) { - out.push({ id: row.id, name: row.name, count: q, unitUsd: row.priceUsd }); + // Compute total + const total = useCallback(() => { + let sum = 0; + for (const [label, qty] of Object.entries(cart)) { + if (qty > 0) { + const price = laundryItems.find(item => item.label.toLowerCase() === label.toLowerCase())?.price || 0; + sum += price * qty; } } - return out; - }, [qty]); + if (sameDay) sum += SAME_DAY_SURCHARGE; + return sum; + }, [cart, sameDay]); - const subtotal = useMemo(() => { - let s = lines.reduce((a, l) => a + l.unitUsd * l.count, 0); - if (express) s += 15; - return s; - }, [lines, express]); + const [displayTotal, setDisplayTotal] = useState(0); + useEffect(() => { + setDisplayTotal(total()); + }, [total]); - function submit() { - if (lines.length === 0 && !express) return; - const detail = [ - ...lines.map((l) => `${l.name} ×${l.count}`), - express ? "Express same-day (+$15)" : null, - ] - .filter(Boolean) - .join("; "); - addOrder({ - category: "laundry", - title: "Laundry · " + (lines.length ? `${lines.length} item type(s)` : "Express only"), - detail, - totalUsd: Math.round(subtotal * 100) / 100, - status: "pending", - }); - setQty({}); - setExpress(false); - setSent(true); + // Build items Json + const buildItems = (): LaundryCartItem[] => + Object.entries(cart) + .filter(([, qty]) => qty > 0) + .map(([label, quantity]) => ({ label: laundryItems.find(i => i.label.toLowerCase() === label.toLowerCase())?.label || label, quantity })); + + async function submit() { + if (!canUseApi || buildItems().length === 0) { + setSubmitErr("Please select at least one item."); + return; + } + setSubmitErr(null); + setSubmitting(true); + try { + await guestPlaceLaundry(propertyId!, accessToken!, { + bookingId: bookingId!, + items: buildItems(), + sameDay, + total: displayTotal, + // currency: "ETB", + notes: notes.trim() || undefined, + pickupAt: pickupAt || undefined, + deliverAt: deliverAt || undefined, + }); + setSubmitting(false); + setCart({}); + setSameDay(false); + setPickupAt(""); + setDeliverAt(""); + setNotes(""); + setSent(true); + } catch (e) { + setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request"); + setSubmitting(false); + } } + const needBooking = !bookingLoading && !bookingId; + const hasItems = buildItems().length > 0; + + const updateQty = (label: string, delta: number) => { + setCart(prev => { + const current = prev[label] || 0; + const newQty = Math.max(0, current + delta); + const newCart = { ...prev }; + if (newQty === 0) delete newCart[label]; + else newCart[label] = newQty; + return newCart; + }); + }; + return (
-

- Laundry service -

-

- Select pieces and optional express surcharge. Mock request — pickup at reception. -

+

Laundry service

+

Submit a laundry request attached to your active booking.

View profile →
- {sent ? ( -
- Request logged (demo). Our team will confirm timing by phone. + {needBooking ? ( +
+ Sign in with a booking code or use a reservation to sync laundry with the hotel.
) : null} -
-
- {laundryItems.map((row) => ( -
+ {submitErr ? ( +
{submitErr}
+ ) : null} + + {sent ? ( +
+ Laundry request submitted successfully. +
+ ) : null} + + {!sent && ( +
+ {/* Items Selection */} +
+ +
+ {laundryItems.map((item) => { + const qty = cart[item.label.toLowerCase()] || 0; + return ( +
+
{item.label}
+
{formatEtb(item.price)} / each
+
+ + {qty} + +
+
+ ); + })} +
+
+ + {/* Summary & Form */} +
+
+ {formatEtb(displayTotal)} {sameDay && (incl. same-day)} +
+ + + +
-

{row.name}

-

- {row.description} · ${row.priceUsd}/{row.unit} -

+ + setPickupAt(e.target.value)} + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm" + />
-
- - {qty[row.id] ?? 0} - +
+ + setDeliverAt(e.target.value)} + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm" + />
- ))} - -
-