merge: integrate origin/main with guest portal updates

Resolve merge conflicts between upstream auth/data refactors and local guest portal navigation updates, then align imports and room data so the combined branch builds successfully.

Made-with: Cursor
This commit is contained in:
“kirukib” 2026-04-27 20:15:43 +03:00
commit 202897e83f
69 changed files with 2425 additions and 1488 deletions

View File

@ -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());

303
package-lock.json generated
View File

@ -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
}
}
}
}
}

View File

@ -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",

View File

@ -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 };

View File

@ -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 | "payment" | "reserve">(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() {
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
Book your stay
</p>
<h1 className="mt-2 font-heading text-3xl md:text-4xl">
It only takes a moment
</h1>
<h1 className="mt-2 font-heading text-3xl md:text-4xl">It only takes a moment</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Pay now, or reserve first and complete payment later in this session mock only.
Live rates from the hotel. You&apos;ll receive a booking code to sign in and manage your stay.
</p>
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
@ -206,8 +228,8 @@ export function BookingPageClient() {
{pending === "reserve" ? "Saving your hold…" : "Reserve now — pay later"}
</button>
<p className="text-center text-xs text-[var(--color-muted)]">
Pay later keeps your details and hold reference; finish checkout from the next screen
whenever you&apos;re ready.
Pay later keeps your hold; you&apos;ll get a booking code. Payment is completed at the hotel unless
you add card checkout later.
</p>
</div>
</div>

View File

@ -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() {
</div>
<h1 className="mt-8 font-heading text-3xl md:text-4xl">Your booking is confirmed</h1>
<p className="mt-3 text-sm text-[var(--color-muted)]">
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}.
</p>
<p className="mt-2 font-mono text-sm text-[var(--color-text)]">
Confirmation: {confirmationId}
</p>
{holdReference ? (
<p className="mt-1 text-xs text-[var(--color-muted)]">
Booking code: <span className="font-mono text-[var(--color-text)]">{holdReference}</span>
</p>
) : null}
{paidAt ? (
<p className="mt-1 text-xs text-[var(--color-muted)]">
Paid at: {new Date(paidAt).toLocaleString()}
@ -85,7 +93,12 @@ export default function ConfirmationPage() {
</span>
</p>
</div>
<p className="font-semibold">Total paid: {formatUsd(total)}</p>
<p className="font-semibold">
Total paid:{" "}
{lastCreatedBooking?.currency === "ETB" || selectedRoom.priceCurrency === "ETB"
? formatEtb(lastCreatedBooking?.totalPrice ?? total)
: formatUsd(lastCreatedBooking?.totalPrice ?? total)}
</p>
</div>
</div>

View File

@ -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"];

View File

@ -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<Record<string, number>>({});
const [express, setExpress] = useState(false);
const { accessToken } = useAuth();
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
// Form states
const [cart, setCart] = useState<Record<string, number>>({});
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<string | null>(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);
// 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;
}
}
if (sameDay) sum += SAME_DAY_SURCHARGE;
return sum;
}, [cart, sameDay]);
const [displayTotal, setDisplayTotal] = useState(0);
useEffect(() => {
setDisplayTotal(total());
}, [total]);
// 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,
});
}
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 });
}
}
return out;
}, [qty]);
const subtotal = useMemo(() => {
let s = lines.reduce((a, l) => a + l.unitUsd * l.count, 0);
if (express) s += 15;
return s;
}, [lines, express]);
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);
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 (
<div className="bg-[var(--color-bg)] pb-24 pt-8 md:pt-12">
<div className="mx-auto max-w-7xl px-4 md:px-8">
<nav className="text-xs font-medium text-[var(--color-muted)]">
<Link href="/" className="hover:text-[var(--color-accent)]">
Home
</Link>
<Link href="/" className="hover:text-[var(--color-accent)]">Home</Link>
<span className="mx-2 opacity-50">/</span>
<Link href="/guest" className="hover:text-[var(--color-accent)]">
Guest hub
</Link>
<Link href="/guest" className="hover:text-[var(--color-accent)]">Guest hub</Link>
<span className="mx-2 opacity-50">/</span>
<span className="text-[var(--color-text)]">Laundry</span>
</nav>
<div className="mt-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
Laundry service
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Select pieces and optional express surcharge. Mock request pickup at reception.
</p>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">Laundry service</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p>
</div>
<Link href="/guest/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
View profile
</Link>
</div>
{sent ? (
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
Request logged (demo). Our team will confirm timing by phone.
{needBooking ? (
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
Sign in with a booking code or use a reservation to sync laundry with the hotel.
</div>
) : null}
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
<div className="space-y-2">
{laundryItems.map((row) => (
<div
key={row.id}
className="flex flex-col gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-semibold text-[var(--color-text)]">{row.name}</p>
<p className="text-sm text-[var(--color-muted)]">
{row.description} · ${row.priceUsd}/{row.unit}
</p>
{submitErr ? (
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">{submitErr}</div>
) : null}
{sent ? (
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
Laundry request submitted successfully.
</div>
) : null}
{!sent && (
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
{/* Items Selection */}
<div className="space-y-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<label className="text-base font-semibold text-[var(--color-text)] block mb-2">Select items</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{laundryItems.map((item) => {
const qty = cart[item.label.toLowerCase()] || 0;
return (
<div key={item.label} className="border border-[var(--color-border)] rounded-xl p-4 bg-[var(--color-surface-muted)]">
<div className="font-medium text-[var(--color-text)] mb-1">{item.label}</div>
<div className="text-sm text-[var(--color-muted)] mb-3">{formatEtb(item.price)} / each</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => bump(row.id, -1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
onClick={() => updateQty(item.label.toLowerCase(), -1)}
disabled={qty === 0}
className="w-10 h-10 rounded-lg border bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50 flex items-center justify-center"
>
</button>
<span className="w-8 text-center font-semibold">{qty[row.id] ?? 0}</span>
<span className="w-16 text-center font-mono text-lg font-semibold">{qty}</span>
<button
type="button"
onClick={() => bump(row.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
onClick={() => updateQty(item.label.toLowerCase(), 1)}
className="w-10 h-10 rounded-lg border bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-dark)] flex items-center justify-center"
>
+
</button>
</div>
</div>
))}
<label className="mt-4 flex cursor-pointer items-center gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] p-4">
<input
type="checkbox"
checked={express}
onChange={(e) => setExpress(e.target.checked)}
className="h-4 w-4 rounded border-[var(--color-border)]"
/>
<span className="text-sm text-[var(--color-text)]">
Express same-day (+$15 per order)
</span>
</label>
);
})}
</div>
</div>
<aside className="lg:sticky lg:top-28">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Summary
</p>
<p className="mt-4 font-heading text-2xl font-semibold">${subtotal.toFixed(2)}</p>
{/* Summary & Form */}
<div className="space-y-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<div className="text-xl font-bold text-[var(--color-text)]">
{formatEtb(displayTotal)} {sameDay && <span className="text-sm text-[var(--color-accent)]">(incl. same-day)</span>}
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={sameDay}
onChange={(e) => setSameDay(e.target.checked)}
className="w-4 h-4 rounded"
/>
<span className="text-sm font-medium text-[var(--color-text)]">Express same-day (+{formatEtb(SAME_DAY_SURCHARGE)})</span>
</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium uppercase tracking-wide text-[var(--color-muted)] mb-1">Pickup</label>
<input
type="datetime-local"
value={pickupAt}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wide text-[var(--color-muted)] mb-1">Delivery</label>
<input
type="datetime-local"
value={deliverAt}
onChange={(e) => 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"
/>
</div>
</div>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Special instructions (e.g., no starch, delicate)..."
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
/>
<button
type="button"
onClick={submit}
disabled={lines.length === 0 && !express}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
disabled={submitting || !hasItems || !canUseApi}
className="w-full rounded-xl bg-[var(--color-accent)] px-6 py-3 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Submit laundry request
{submitting ? "Submitting..." : `Place laundry order (${formatEtb(displayTotal)})`}
</button>
</div>
</aside>
</div>
)}
</div>
</div>
);

View File

@ -12,13 +12,7 @@ export function LoginPageClient() {
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") || "/guest/profile";
const {
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
} = useAuth();
const { requestOtp, verifyOtp, loginPassword, loginGoogle, loginBookingRef } = useAuth();
const [tab, setTab] = useState<Tab>("otp");
const [email, setEmail] = useState("");
@ -68,15 +62,17 @@ export function LoginPageClient() {
if (r.ok) router.push(nextPath);
}
function handleSocial(provider: "google" | "apple" | "facebook") {
loginSocial(provider);
router.push(nextPath);
async function handleGoogle() {
setMessage(null);
await loginGoogle();
}
function handleBookingRef(e: React.FormEvent) {
async function handleBookingRef(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
const r = loginBookingRef(bookingRef, bookingLastName || undefined);
setLoading(true);
const r = await loginBookingRef(bookingRef);
setLoading(false);
setMessage(r.message);
if (r.ok) router.push(nextPath);
}
@ -175,7 +171,7 @@ export function LoginPageClient() {
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
Demo: enter <strong>123456</strong>
Use the code sent to your email (hotel guest OTP).
</p>
<div className="flex gap-2">
<button
@ -225,7 +221,7 @@ export function LoginPageClient() {
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
Demo password: <strong>shitaye</strong> or <strong>demo123</strong>
Use your Yaltopia homes account password
</p>
<button
type="submit"
@ -239,32 +235,20 @@ export function LoginPageClient() {
{tab === "social" && (
<div className="space-y-3">
<p className="text-sm text-[var(--color-muted)]">
Mock sign-in no external redirect in this demo.
</p>
<button
type="button"
onClick={() => handleSocial("google")}
onClick={() => void handleGoogle()}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-3 text-sm font-semibold transition hover:bg-[var(--color-surface-muted)]"
>
<span className="text-base" aria-hidden>
G
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
</span>
Continue with Google
</button>
<button
type="button"
onClick={() => handleSocial("apple")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-text)] py-3 text-sm font-semibold text-white transition hover:opacity-90"
>
Continue with Apple
</button>
<button
type="button"
onClick={() => handleSocial("facebook")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[#1877f2] py-3 text-sm font-semibold text-white transition hover:opacity-95"
>
Continue with Facebook
Login with Google
</button>
</div>
)}
@ -296,13 +280,15 @@ export function LoginPageClient() {
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
<strong>Legacy codes</strong> (no last name): SHITAYE-2026-DEMO, GUEST-1234.
<br />
<strong>Mock payment / hold:</strong> PAY-MOCK-CONFIRMED + last name{" "}
<strong>Demo</strong> · SHY-MOCK-HOLD + last name <strong>Hold</strong>.
Enter the booking code from your confirmation email. You must have used the same
email at booking so your account links for the full guest portal.
</p>
<button type="submit" className="btn-mustard w-full justify-center py-3 text-sm">
Continue with booking ID
<button
type="submit"
disabled={loading}
className="btn-mustard w-full justify-center py-3 text-sm disabled:opacity-60"
>
{loading ? "Signing in…" : "Continue with booking code"}
</button>
</form>
)}

View File

@ -1,18 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { siteConfig } from "@/lib/mocks/site";
"use client";
export const metadata: Metadata = {
title: "Guest hub",
description: "Digital room service, laundry, gym, and spa — order during your stay at Shitaye.",
};
import Link from "next/link";
import { siteConfig } from "@/lib/site-config";
import { useAuth } from "@/context/AuthContext";
const tiles = [
{
href: "/guest/room-service",
title: "Digital menu",
subtitle: "Room service",
desc: "Breakfast through late evening — add to tray and send to the kitchen (demo).",
desc: "Breakfast through late evening — send orders directly to the kitchen.",
icon: "🍽",
},
{
@ -39,6 +36,8 @@ const tiles = [
];
export default function GuestHubPage() {
const { session } = useAuth();
return (
<div className="bg-[var(--color-bg)]">
<section className="border-b border-[var(--color-border)] bg-pattern-brand-gold py-14 md:py-20">
@ -54,20 +53,27 @@ export default function GuestHubPage() {
During your stay
</h1>
<p className="mt-5 max-w-2xl text-sm leading-relaxed text-[var(--color-muted)] md:text-base">
Order to your room, schedule laundry, and book gym & spa all in one place. Sign in with
email or{" "}
Order to your room, schedule laundry, and book gym & spa all in one place.
{!session && (
<>
{" "}
Sign in with email or{" "}
<span className="font-medium text-[var(--color-text)]">booking reference</span> to track
orders on your profile.
</>
)}
</p>
<div className="mt-8 flex flex-wrap gap-3">
{!session && (
<Link href="/guest/login" className="btn-mustard px-6 py-3 text-sm">
Sign in
</Link>
)}
<Link
href="/guest/profile"
className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-3 text-sm font-semibold text-[var(--color-text)] transition hover:border-[var(--color-accent)]"
>
My stay profile
{session ? "My profile & orders" : "My stay profile"}
</Link>
</div>
</div>

View File

@ -9,8 +9,8 @@ import {
seedAppointments,
seedRewardsHistory,
seedShuttle,
} from "@/lib/mocks/guestData";
import { siteConfig } from "@/lib/mocks/site";
} from "@/lib/data/guestData";
import { siteConfig } from "@/lib/site-config";
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
{ id: "all", label: "All" },
@ -90,24 +90,12 @@ function ProfileContent() {
<div className="mt-6 flex flex-col gap-4 border-b border-[var(--color-border)] pb-8 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
{session.kind === "member"
? `Hello, ${session.displayName}`
: `Welcome, ${session.guestName}`}
{`Hello, ${session.displayName}`}
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
{session.kind === "member" ? (
<>
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{" · "}
Signed in via {session.authMethod}
</>
) : (
<>
Booking <span className="font-mono font-semibold">{session.bookingRef}</span>
{" · "}
{session.roomLabel} · checkout {session.checkOut}
</>
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
@ -129,21 +117,14 @@ function ProfileContent() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Rewards points
</p>
{session.kind === "member" ? (
<>
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{session.points.toLocaleString()}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{session.tier} tier · earn on stays & dining
Loyalty balance from your signed-in guest account
</p>
</>
) : (
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
Full loyalty points unlock when you sign in with email. Booking-ID access covers
orders and stay tools.
</p>
)}
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm lg:col-span-2">

View File

@ -2,15 +2,17 @@
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import {
roomServiceCategories,
roomServiceItems,
type MenuCategory,
type MenuItem,
} from "@/lib/mocks/roomServiceMenu";
import { guestMenuItems, guestPlaceRoomService, type MenuItemRow } from "@/lib/guest-hotel-api";
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
const API_CATEGORY_LABEL: Record<string, string> = {
FOOD: "Food",
BEVERAGE: "Beverages",
EXTRA: "Extras",
};
export function RoomServiceClient() {
return (
@ -21,15 +23,63 @@ export function RoomServiceClient() {
}
function RoomServiceInner() {
const { addOrder } = useAuth();
const [cat, setCat] = useState<MenuCategory>("breakfast");
const { accessToken } = useAuth();
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
const [cat, setCat] = useState<string>("");
const [qty, setQty] = useState<Record<string, number>>({});
const [sent, setSent] = useState(false);
const [apiMenu, setApiMenu] = useState<MenuItemRow[] | null>(null);
const [menuReady, setMenuReady] = useState(false);
const [submitErr, setSubmitErr] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const items = useMemo(
() => roomServiceItems.filter((i) => i.category === cat),
[cat],
);
useEffect(() => {
if (!accessToken || !propertyId) {
setApiMenu(null);
setMenuReady(true);
return;
}
let cancelled = false;
guestMenuItems(propertyId, accessToken)
.then((r) => {
if (!cancelled) setApiMenu(r.data ?? []);
})
.catch(() => {
if (!cancelled) setApiMenu([]);
})
.finally(() => {
if (!cancelled) setMenuReady(true);
});
return () => {
cancelled = true;
};
}, [accessToken, propertyId]);
const useApi = true;
const tabs = useMemo(() => {
if (!apiMenu) return [];
const seen = new Set<string>();
const out: { id: string; label: string }[] = [];
for (const i of apiMenu) {
const c = String(i.category ?? "FOOD");
if (!seen.has(c)) {
seen.add(c);
out.push({ id: c, label: API_CATEGORY_LABEL[c] ?? c });
}
}
return out;
}, [apiMenu]);
useEffect(() => {
if (!tabs.length) return;
if (!cat || !tabs.some((t) => t.id === cat)) setCat(tabs[0].id);
}, [tabs, cat]);
const items = useMemo(() => {
if (!apiMenu) return [];
return apiMenu.filter((i) => String(i.category) === cat);
}, [apiMenu, cat]);
function bump(id: string, delta: number) {
setQty((prev) => {
@ -42,34 +92,42 @@ function RoomServiceInner() {
}
const cartLines = useMemo(() => {
const lines: { item: MenuItem; count: number }[] = [];
const lines: { id: string; name: string; unit: number; count: number }[] = [];
for (const id of Object.keys(qty)) {
const item = roomServiceItems.find((i) => i.id === id);
const row = apiMenu?.find((i) => i.id === id);
const count = qty[id];
if (item && count > 0) lines.push({ item, count });
if (row && count > 0) {
lines.push({ id: row.id, name: row.name, unit: Number(row.unitPrice), count });
}
}
return lines;
}, [qty]);
}, [qty, apiMenu]);
const subtotal = useMemo(
() => cartLines.reduce((s, l) => s + l.item.priceUsd * l.count, 0),
[cartLines],
);
const subtotal = useMemo(() => {
return cartLines.reduce((s, l) => s + l.unit * l.count, 0);
}, [cartLines]);
function submit() {
async function submit() {
if (cartLines.length === 0) return;
const detail = cartLines.map((l) => `${l.item.name} ×${l.count}`).join("; ");
addOrder({
category: "room-service",
title: `Room service · ${cartLines.length} line(s)`,
detail,
totalUsd: Math.round(subtotal * 100) / 100,
status: "pending",
});
setSubmitErr(null);
if (!propertyId || !accessToken || !bookingId) return;
setSubmitting(true);
try {
const lines = cartLines.map((l) => ({ menuItemId: l.id, quantity: l.count }));
await guestPlaceRoomService(propertyId, accessToken, { bookingId, lines });
} catch (e) {
setSubmitErr(e instanceof Error ? e.message : "Could not place order");
setSubmitting(false);
return;
}
setSubmitting(false);
setQty({});
setSent(true);
}
const needBooking = useApi && !bookingLoading && !bookingId;
return (
<div className="bg-[var(--color-bg)] pb-24 pt-8 md:pt-12">
<div className="mx-auto max-w-7xl px-4 md:px-8">
@ -91,23 +149,44 @@ function RoomServiceInner() {
Digital menu
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Mock ordering your tray appears on your profile under orders. Service charges may
apply.
Orders go to the hotel kitchen when you are checked in with an active booking.
</p>
</div>
<Link href="/guest/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
<Link
href="/guest/profile"
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
View profile
</Link>
</div>
{!menuReady ? (
<p className="mt-6 text-sm text-[var(--color-muted)]">Loading menu</p>
) : null}
{menuReady && items.length === 0 ? (
<p className="mt-6 text-sm text-[var(--color-muted)]">No menu items are currently available.</p>
) : null}
{needBooking ? (
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
Sign in with a booking code or book a stay so we can attach room service to your reservation.
</div>
) : null}
{submitErr ? (
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
{submitErr}
</div>
) : null}
{sent ? (
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
Order sent to the kitchen queue (demo). Add another round or check your profile.
Order submitted. You can add another round or check your profile for the local tray summary.
</div>
) : null}
<div className="mt-8 flex flex-wrap gap-2">
{roomServiceCategories.map((c) => (
{tabs.map((c) => (
<button
key={c.id}
type="button"
@ -125,14 +204,14 @@ function RoomServiceInner() {
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_360px]">
<div className="grid gap-4 sm:grid-cols-2">
{items.map((item) => (
{items.map((row) => (
<article
key={item.id}
key={row.id}
className="flex flex-col rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-[var(--color-surface-muted)]">
<Image
src="https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=600&q=80"
src={row.image}
alt=""
fill
className="object-cover opacity-90"
@ -140,25 +219,31 @@ function RoomServiceInner() {
/>
</div>
<h2 className="mt-3 font-heading text-lg font-semibold text-[var(--color-text)]">
{item.name}
{row.name}
</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">{item.description}</p>
{row.description ? (
<p className="mt-1 text-sm text-[var(--color-muted)]">{row.description}</p>
) : null}
<p className="mt-2 text-sm font-semibold text-[var(--color-primary)]">
${item.priceUsd}
{Number(row.unitPrice).toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => bump(item.id, -1)}
onClick={() => bump(row.id, -1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Decrease"
>
</button>
<span className="w-8 text-center font-semibold">{qty[item.id] ?? 0}</span>
<span className="w-8 text-center font-semibold">{qty[row.id] ?? 0}</span>
<button
type="button"
onClick={() => bump(item.id, 1)}
onClick={() => bump(row.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Increase"
>
@ -179,26 +264,38 @@ function RoomServiceInner() {
) : (
<ul className="mt-4 space-y-2 text-sm">
{cartLines.map((l) => (
<li key={l.item.id} className="flex justify-between gap-2">
<li key={l.id} className="flex justify-between gap-2">
<span className="text-[var(--color-text)]">
{l.item.name} ×{l.count}
{l.name} ×{l.count}
</span>
<span className="font-medium">
{(l.unit * l.count).toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</span>
<span className="font-medium">${(l.item.priceUsd * l.count).toFixed(0)}</span>
</li>
))}
</ul>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-[var(--color-border)] pt-4">
<span className="text-sm text-[var(--color-muted)]">Subtotal</span>
<span className="font-heading text-xl font-semibold">${subtotal.toFixed(2)}</span>
<span className="font-heading text-xl font-semibold">
{subtotal.toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</span>
</div>
<button
type="button"
onClick={submit}
disabled={cartLines.length === 0}
onClick={() => void submit()}
disabled={cartLines.length === 0 || submitting || !!needBooking || bookingLoading}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
Send to kitchen
{submitting ? "Sending…" : "Send to kitchen"}
</button>
</div>
</aside>

View File

@ -3,12 +3,12 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { AmenityItem } from "@/components/AmenityItem";
import { MeetingHalfDayRate } from "@/components/MeetingHalfDayRate";
import { roomAmenities } from "@/lib/mocks/amenities";
import { roomAmenities } from "@/lib/data/amenities";
import {
getAllMeetingSlugs,
getMeetingSpaceBySlug,
} from "@/lib/mocks/meetingSpaces";
import { siteConfig } from "@/lib/mocks/site";
} from "@/lib/data/meetingSpaces";
import { siteConfig } from "@/lib/site-config";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };

View File

@ -3,15 +3,14 @@ import Link from "next/link";
import { AmenityItem } from "@/components/AmenityItem";
import { BookingSearchWidget } from "@/components/BookingSearchWidget";
import { OutletCard } from "@/components/OutletCard";
import { RoomCard } from "@/components/RoomCard";
import { CatalogRoomsSection } from "@/components/CatalogRoomsSection";
import { GoogleMapEmbed } from "@/components/GoogleMapEmbed";
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
import { roomAmenities } from "@/lib/mocks/amenities";
import { bookingStyleReviews } from "@/lib/mocks/bookingReviews";
import { outlets } from "@/lib/mocks/outlets";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import { wellnessFacilities } from "@/lib/mocks/wellness";
import { roomAmenities } from "@/lib/data/amenities";
import { bookingStyleReviews } from "@/lib/data/bookingReviews";
import { outlets } from "@/lib/data/outlets";
import { siteConfig } from "@/lib/site-config";
import { wellnessFacilities } from "@/lib/data/wellness";
const heroImage =
"https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1920&q=80";
@ -164,11 +163,7 @@ export default function HomePage() {
Book a room
</Link>
</div>
<div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
<CatalogRoomsSection />
</div>
</section>

View File

@ -6,9 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext";
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { processPayment } from "@/lib/mocks/api";
import { formatEtb } from "@/lib/format-etb";
export function PaymentPageClient() {
const router = useRouter();
@ -27,6 +27,7 @@ export function PaymentPageClient() {
holdReference,
payLaterHold,
setConfirmation,
lastCreatedBooking,
} = useBooking();
const { formatUsd } = useCurrency();
@ -38,26 +39,31 @@ export function PaymentPageClient() {
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!selectedRoom || !guest.email) {
if (!selectedRoom || !guest.email || !holdReference) {
router.replace("/booking");
}
}, [selectedRoom, guest.email, router]);
}, [selectedRoom, guest.email, holdReference, router]);
if (!selectedRoom) {
if (!selectedRoom || !holdReference) {
return null;
}
const payLabel = `Confirm & pay ${formatUsd(total)}`;
const payTotal =
lastCreatedBooking?.totalPrice != null && Number.isFinite(lastCreatedBooking.totalPrice)
? lastCreatedBooking.totalPrice
: total;
const payIsEtb =
lastCreatedBooking?.currency === "ETB" || selectedRoom?.priceCurrency === "ETB";
const payLabel = payIsEtb
? `Confirm & pay ${formatEtb(payTotal, 2)}`
: `Confirm & pay ${formatUsd(payTotal)}`;
async function handlePay() {
setLoading(true);
try {
const last4 = cardNumber.replace(/\D/g, "").slice(-4) || "0000";
const result = await processPayment({
totalCents: Math.round(total * 100),
last4,
});
setConfirmation(result.confirmationId, result.paidAt);
// Card UI is a placeholder; settlement is at the hotel until Stripe is wired.
const id = lastCreatedBooking?.id ?? holdReference ?? "confirmed";
setConfirmation(id, new Date().toISOString());
router.push("/confirmation");
} finally {
setLoading(false);
@ -68,7 +74,8 @@ export function PaymentPageClient() {
<div className="mx-auto max-w-2xl px-4 py-12 md:py-16">
<h1 className="font-heading text-3xl">Payment</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Mock form only read our privacy policy before a real launch.
Payment gateway is not connected yet confirming here records intent; settle at the front desk
or add a card processor later.
</p>
{payLaterHold ? (
@ -87,7 +94,7 @@ export function PaymentPageClient() {
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--color-muted)]">
Card details (demo)
Card details (optional placeholder)
</h2>
<label className="mt-4 block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">Cardholder name</span>
@ -156,23 +163,25 @@ export function PaymentPageClient() {
<dl className="mt-6 space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-[var(--color-muted)]">
{formatUsd(selectedRoom.nightlyRate)} × {nights} nights
{payIsEtb
? `${formatEtb(selectedRoom.nightlyRate, 0)} × ${nights} nights`
: `${formatUsd(selectedRoom.nightlyRate)} × ${nights} nights`}
</dt>
<dd>{formatUsd(subtotal)}</dd>
<dd>{payIsEtb ? formatEtb(subtotal) : formatUsd(subtotal)}</dd>
</div>
{discountAmount > 0 ? (
<div className="flex justify-between text-[var(--color-success)]">
<dt>Discount</dt>
<dd>-{formatUsd(discountAmount)}</dd>
<dd>{payIsEtb ? `-${formatEtb(discountAmount)}` : `-${formatUsd(discountAmount)}`}</dd>
</div>
) : null}
<div className="flex justify-between">
<dt className="text-[var(--color-muted)]">Taxes & fees ({siteConfig.taxRate * 100}%)</dt>
<dd>{formatUsd(taxAmount)}</dd>
<dd>{payIsEtb ? formatEtb(taxAmount) : formatUsd(taxAmount)}</dd>
</div>
<div className="flex justify-between border-t border-[var(--color-border)] pt-3 text-base font-semibold">
<dt>Total</dt>
<dd>{formatUsd(total)}</dd>
<dd>{payIsEtb ? formatEtb(total) : formatUsd(total)}</dd>
</div>
</dl>
</div>

View File

@ -1,16 +1,15 @@
"use client";
import { GuestSessionProvider } from "@/context/GuestSessionContext";
import { BookingProvider } from "@/context/BookingContext";
import { CurrencyProvider } from "@/context/CurrencyContext";
import { SessionProvider } from "next-auth/react";
import { StoreHydration } from "@/components/StoreHydration";
import { AuthProvider } from "@/context/AuthContext";
import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return (
<CurrencyProvider>
<GuestSessionProvider>
<BookingProvider>{children}</BookingProvider>
</GuestSessionProvider>
</CurrencyProvider>
<SessionProvider refetchOnWindowFocus>
<StoreHydration />
<AuthProvider>{children}</AuthProvider>
</SessionProvider>
);
}

View File

@ -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 ReserveHeldPage() {
const router = useRouter();
@ -60,14 +61,16 @@ export default function ReserveHeldPage() {
</h1>
<p className="mt-3 text-center text-sm text-[var(--color-muted)]">
{guest.firstName}, your room is saved finish payment whenever you&apos;re ready in this
browser session. (Demo: no real hold or email.)
browser session.
</p>
<p className="mt-2 text-center font-mono text-sm text-[var(--color-text)]">
Hold ref: {holdReference}
Booking code: {holdReference}
</p>
<p className="mt-2 text-center text-xs text-[var(--color-muted)]">
Indicative total when you pay:{" "}
<span className="font-semibold text-[var(--color-text)]">{formatUsd(total)}</span>
<span className="font-semibold text-[var(--color-text)]">
{selectedRoom.priceCurrency === "ETB" ? formatEtb(total) : formatUsd(total)}
</span>
</p>
<div className="mt-10 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] text-left shadow-sm">

View File

@ -5,20 +5,20 @@ import { AmenityItem } from "@/components/AmenityItem";
import { FormattedUsd } from "@/components/FormattedUsd";
import { BookRoomButton } from "@/components/BookRoomButton";
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
import { roomAmenities } from "@/lib/mocks/amenities";
import { getAllRoomSlugs, getRoomBySlug } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import { roomAmenities } from "@/lib/data/amenities";
import { getAllMarketingRoomSlugs, getMarketingRoomBySlug } from "@/lib/data/marketing-room-pages";
import { siteConfig } from "@/lib/site-config";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return getAllRoomSlugs().map((slug) => ({ slug }));
return getAllMarketingRoomSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const room = getRoomBySlug(slug);
const room = getMarketingRoomBySlug(slug);
if (!room) return { title: "Room" };
return {
title: room.name,
@ -28,7 +28,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function RoomPage({ params }: Props) {
const { slug } = await params;
const room = getRoomBySlug(slug);
const room = getMarketingRoomBySlug(slug);
if (!room) notFound();
return (

View File

@ -1,167 +1,25 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
spaGymFilters,
spaGymServices,
type SpaGymFilterId,
type SpaGymService,
} from "@/lib/mocks/services";
import { siteConfig } from "@/lib/mocks/site";
function ServiceCard({
service,
selected,
onToggle,
}: {
service: SpaGymService;
selected: boolean;
onToggle: () => void;
}) {
const kindLabel = service.kind === "spa" ? "Spa" : "Gym";
return (
<article className="card-lift flex flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm">
<div className="relative aspect-[16/10] overflow-hidden">
<Image
src={service.image}
alt=""
fill
className="object-cover transition duration-500"
sizes="(max-width:640px) 100vw, (max-width:1024px) 50vw, 33vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<span className="absolute left-3 top-3 rounded-full bg-[var(--color-surface)]/95 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[var(--color-primary)] shadow-sm backdrop-blur-sm">
{kindLabel}
</span>
<span className="absolute bottom-3 right-3 rounded-full bg-[var(--color-primary)] px-3 py-1 text-xs font-bold text-[var(--color-on-primary)] shadow-md">
${service.priceUsd}
<span className="font-normal opacity-90"> · {service.priceNote}</span>
</span>
</div>
<div className="flex flex-1 flex-col p-5 md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-muted)]">
{service.duration}
</p>
<h3 className="mt-2 font-heading text-lg font-semibold text-[var(--color-text)] md:text-xl">
{service.title}
</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-[var(--color-muted)]">
{service.description}
</p>
<button
type="button"
onClick={onToggle}
aria-pressed={selected}
className={`mt-5 w-full rounded-full border-2 border-transparent px-4 py-2.5 text-sm font-semibold transition md:mt-6 ${
selected
? "bg-[var(--color-primary)] text-[var(--color-on-primary)] shadow-md"
: "bg-[var(--color-accent-soft)] text-[var(--color-primary)] ring-1 ring-[var(--color-accent)]/40 hover:bg-[var(--color-accent)]/15"
}`}
>
{selected ? "Added — tap to remove" : "Add to selection"}
</button>
</div>
</article>
);
}
function SelectionPanel({
items,
onRemove,
onClear,
}: {
items: SpaGymService[];
onRemove: (id: string) => void;
onClear: () => void;
}) {
const total = useMemo(
() => items.reduce((sum, s) => sum + s.priceUsd, 0),
[items],
);
return (
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Your selection
</p>
<p className="mt-1 text-xs text-[var(--color-muted)]">
Mock basket pick services to preview a request (no real payment).
</p>
{items.length === 0 ? (
<p className="mt-6 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-center text-sm text-[var(--color-muted)]">
Tap &ldquo;Add to selection&rdquo; on any spa or gym service to build your list.
</p>
) : (
<ul className="mt-5 max-h-[min(320px,50vh)] space-y-3 overflow-y-auto pr-1">
{items.map((s) => (
<li
key={s.id}
className="flex items-start justify-between gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2.5 text-sm"
>
<div className="min-w-0">
<p className="font-semibold text-[var(--color-text)]">{s.title}</p>
<p className="text-xs text-[var(--color-muted)]">
{s.kind === "spa" ? "Spa" : "Gym"} · {s.duration}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-semibold text-[var(--color-primary)]">${s.priceUsd}</span>
<button
type="button"
onClick={() => onRemove(s.id)}
className="rounded-full p-1 text-[var(--color-muted)] transition hover:bg-[var(--color-border)]/50 hover:text-[var(--color-text)]"
aria-label={`Remove ${s.title}`}
>
×
</button>
</div>
</li>
))}
</ul>
)}
{items.length > 0 ? (
<div className="mt-5 border-t border-[var(--color-border)] pt-4">
<div className="flex items-center justify-between text-sm">
<span className="text-[var(--color-muted)]">Subtotal (mock)</span>
<span className="font-heading text-xl font-semibold text-[var(--color-text)]">
${total.toFixed(0)}
</span>
</div>
<div className="mt-4 flex flex-col gap-2">
<a
href={`mailto:${siteConfig.email}?subject=Spa%20%26%20Gym%20request&body=${encodeURIComponent(
`Selected services:\n${items.map((s) => `- ${s.title} ($${s.priceUsd})`).join("\n")}\n\nTotal (estimate): $${total}`,
)}`}
className="btn-mustard px-4 py-3 text-center text-sm"
>
Email request
</a>
<button
type="button"
onClick={onClear}
className="rounded-full border border-[var(--color-border)] py-2.5 text-sm font-semibold text-[var(--color-muted)] transition hover:bg-[var(--color-surface-muted)]"
>
Clear selection
</button>
</div>
</div>
) : null}
</div>
);
}
import { formatEtb } from "@/lib/format-etb";
import { guestCreateSpaBooking, guestSpaBookings, guestSpaOfferings, type SpaBookingRow, type SpaOfferingRow } from "@/lib/guest-hotel-api";
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
import { spaGymFilters } from "@/lib/data/services";
export function ServicesPageClient() {
const searchParams = useSearchParams();
const { session, addOrder } = useAuth();
const [filter, setFilter] = useState<SpaGymFilterId>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const { session, accessToken } = useAuth();
const { bookingId, propertyId } = useGuestActiveBooking();
const [filter, setFilter] = useState<"all" | "spa" | "gym">("all");
const [offerings, setOfferings] = useState<SpaOfferingRow[]>([]);
const [bookings, setBookings] = useState<SpaBookingRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
useEffect(() => {
const k = searchParams.get("kind");
@ -170,49 +28,47 @@ export function ServicesPageClient() {
}
}, [searchParams]);
useEffect(() => {
if (!accessToken || !propertyId) return;
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([guestSpaOfferings(propertyId, accessToken), guestSpaBookings(propertyId, accessToken)])
.then(([off, b]) => {
if (!cancelled) {
setOfferings(off.data ?? []);
setBookings(b.data ?? []);
}
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load services.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [accessToken, propertyId]);
const filtered = useMemo(() => {
if (filter === "all") return spaGymServices;
return spaGymServices.filter((s) => s.kind === filter);
}, [filter]);
if (filter === "all") return offerings;
return offerings.filter((o) => (filter === "spa" ? o.kind !== "GYM_PASS" : o.kind === "GYM_PASS"));
}, [filter, offerings]);
const selectedItems = useMemo(
() => spaGymServices.filter((s) => selected.has(s.id)),
[selected],
);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
async function book(offering: SpaOfferingRow) {
if (!accessToken || !propertyId || !bookingId) return;
setBusyId(offering.id);
setError(null);
try {
await guestCreateSpaBooking(propertyId, accessToken, { bookingId, offeringId: offering.id });
const latest = await guestSpaBookings(propertyId, accessToken);
setBookings(latest.data ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not book service.");
} finally {
setBusyId(null);
}
function remove(id: string) {
setSelected((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
function clear() {
setSelected(new Set());
}
function saveSelectionToProfile() {
if (!session || selectedItems.length === 0) return;
for (const s of selectedItems) {
addOrder({
category: s.kind === "spa" ? "spa" : "gym",
title: `${s.kind === "spa" ? "Spa" : "Gym"} · ${s.title}`,
detail: `${s.duration} · $${s.priceUsd} (${s.priceNote})`,
totalUsd: s.priceUsd,
status: "pending",
});
}
clear();
}
return (
@ -230,18 +86,12 @@ export function ServicesPageClient() {
Spa & gym services
</h1>
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--color-muted)] md:text-base">
Choose treatments and gym sessions your selection is shown on the right (desktop) or
below on mobile. This is a demo flow; confirm times and pricing at the desk.
</p>
<p className="mt-2 text-xs text-[var(--color-muted)]">
Taxes and service charges may apply. Prices shown in USD (mock).
Book treatments and gym passes directly from live hotel offerings.
</p>
</div>
</section>
<section
className={`mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14 ${selectedItems.length > 0 ? "pb-28 lg:pb-14" : ""}`}
>
<section className="mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14">
<div className="flex flex-wrap justify-center gap-2 md:justify-start md:gap-2.5">
{spaGymFilters.map((f) => {
const active = filter === f.id;
@ -262,29 +112,95 @@ export function ServicesPageClient() {
})}
</div>
{error ? (
<p className="mt-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">
{error}
</p>
) : null}
{!bookingId && session ? (
<p className="mt-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Sign in with a booking code (or create a booking) to place gym/spa appointments.
</p>
) : null}
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px] lg:items-start lg:gap-12">
<div className="grid gap-6 sm:grid-cols-2">
{loading ? (
<p className="text-sm text-[var(--color-muted)]">Loading services</p>
) : null}
{!loading && filtered.length === 0 ? (
<p className="text-sm text-[var(--color-muted)]">No offerings published yet.</p>
) : null}
{filtered.map((service) => (
<ServiceCard
<article
key={service.id}
service={service}
selected={selected.has(service.id)}
onToggle={() => toggle(service.id)}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
>
{service.image && (
<div className="mb-4 overflow-hidden rounded-xl">
<Image
src={service.image}
alt={service.name}
width={400}
height={160}
className="h-40 w-full object-cover"
/>
</div>
)}
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
{service.kind === "GYM_PASS" ? "Gym" : "Spa"}
</p>
<h3 className="mt-2 font-heading text-xl text-[var(--color-text)]">
{service.name}
</h3>
<p className="mt-2 text-sm text-[var(--color-muted)]">
{service.description ?? "—"}
</p>
<p className="mt-3 text-sm font-semibold text-[var(--color-text)]">
{formatEtb(Number(service.price ?? 0), 0)}
{service.durationMinutes ? ` · ${service.durationMinutes} min` : ""}
</p>
<button
type="button"
disabled={!bookingId || busyId === service.id}
onClick={() => void book(service)}
className="btn-mustard mt-4 w-full justify-center py-2.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
{busyId === service.id ? "Booking..." : "Book appointment"}
</button>
</article>
))}
</div>
<aside className="lg:sticky lg:top-28">
<SelectionPanel items={selectedItems} onRemove={remove} onClear={clear} />
{session && selectedItems.length > 0 ? (
<button
type="button"
onClick={saveSelectionToProfile}
className="mt-4 w-full rounded-full border-2 border-[var(--color-primary)] bg-[var(--color-surface)] py-3 text-sm font-semibold text-[var(--color-primary)] transition hover:bg-[var(--color-primary)] hover:text-[var(--color-on-primary)]"
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Your appointments
</p>
{bookings.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-muted)]">No bookings yet.</p>
) : (
<ul className="mt-4 space-y-2 text-sm">
{bookings.map((b) => (
<li
key={b.id}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2"
>
Save selection to my stay
</button>
) : null}
<p className="font-medium text-[var(--color-text)]">{b.offering?.name ?? "Service"}</p>
<p className="text-xs text-[var(--color-muted)]">
{b.status} · {new Date(b.scheduledAt ?? b.createdAt).toLocaleString()}
</p>
</li>
))}
</ul>
)}
</div>
<Link
href="/guest"
className="mt-3 block text-center text-sm font-medium text-[var(--color-muted)] hover:text-[var(--color-accent)]"
@ -299,24 +215,6 @@ export function ServicesPageClient() {
</Link>
</aside>
</div>
{/* Mobile sticky summary bar */}
{selectedItems.length > 0 ? (
<div className="fixed bottom-0 left-0 right-0 z-30 border-t border-[var(--color-border)] bg-[var(--color-surface)]/95 p-4 shadow-[0_-8px_30px_rgba(0,0,0,0.08)] backdrop-blur-md lg:hidden">
<div className="mx-auto flex max-w-lg items-center justify-between gap-3">
<p className="text-sm text-[var(--color-muted)]">
<span className="font-semibold text-[var(--color-text)]">{selectedItems.length}</span>{" "}
selected
</p>
<a
href={`mailto:${siteConfig.email}?subject=Spa%20%26%20Gym%20request`}
className="btn-mustard shrink-0 px-5 py-2.5 text-sm"
>
Email request
</a>
</div>
</div>
) : null}
</section>
</div>
);

View File

@ -6,7 +6,7 @@ import { ServicesPageClient } from "./ServicesPageClient";
export const metadata: Metadata = {
title: "Spa & gym services",
description:
"Browse spa treatments and gym sessions at Shitaye Suite Hotel — build a mock selection and send a request.",
"Browse and book live spa treatments and gym sessions at Shitaye Suite Hotel.",
};
export default function ServicesPage() {

View File

@ -1,5 +1,5 @@
import { AmenityIcon } from "@/components/icons/AmenityIcon";
import type { AmenityWithIcon } from "@/lib/mocks/amenities";
import type { AmenityWithIcon } from "@/lib/data/amenities";
type Props = {
item: AmenityWithIcon;

View File

@ -2,17 +2,24 @@
import { useRouter } from "next/navigation";
import { useBooking } from "@/context/BookingContext";
import { useAuth } from "@/context/AuthContext";
type Props = { roomId: string; className?: string };
export function BookRoomButton({ roomId, className = "" }: Props) {
const { setRoomId } = useBooking();
const { session } = useAuth();
const router = useRouter();
const hasActiveStay = !!session?.bookingId || !!session?.bookingCode;
return (
<button
type="button"
onClick={() => {
if (hasActiveStay) {
router.push("/profile");
return;
}
setRoomId(roomId);
router.push("/booking");
}}
@ -21,7 +28,7 @@ export function BookRoomButton({ roomId, className = "" }: Props) {
"btn-mustard px-8 py-3.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
}
>
Book this room
{hasActiveStay ? "View my stay" : "Book this room"}
</button>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
export function CallUsFab() {
const tel = siteConfig.primaryPhone.replace(/\s/g, "");

View File

@ -0,0 +1,31 @@
"use client";
import { RoomCard } from "@/components/RoomCard";
import { useBooking } from "@/context/BookingContext";
export function CatalogRoomsSection() {
const { rooms, roomsLoading, roomsError } = useBooking();
return (
<>
{roomsError ? (
<p className="mt-4 rounded-xl border border-amber-200/80 bg-amber-50 px-4 py-3 text-sm text-amber-950">
{roomsError}
</p>
) : null}
{roomsLoading ? (
<p className="mt-12 text-center text-sm text-[var(--color-muted)]">Loading rooms</p>
) : null}
{!roomsLoading && rooms.length === 0 && !roomsError ? (
<p className="mt-12 text-center text-sm text-[var(--color-muted)]">
No rooms available for booking yet.
</p>
) : null}
<div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{rooms.map((room) => (
<RoomCard key={room.id} room={room} />
))}
</div>
</>
);
}

View File

@ -1,6 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
export function Footer() {
return (

View File

@ -1,8 +1,7 @@
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
/**
* Google Maps embed (search result for the hotel). Uses the same pattern as
* Maps Share Embed without requiring an API key.
*/
export function GoogleMapEmbed({ className = "" }: { className?: string }) {
return (

View File

@ -1,12 +1,17 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { HeaderAccount } from "@/components/HeaderAccount";
import { HeaderNav } from "@/components/HeaderNav";
import { CurrencySwitcher } from "@/components/CurrencySwitcher";
import { ReviewsMenu } from "@/components/ReviewsMenu";
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
import { useAuth } from "@/context/AuthContext";
export function Header() {
const { session } = useAuth();
return (
<header className="sticky top-0 z-40">
<div className="border-b border-white/10 bg-[var(--color-navy)] text-white">
@ -45,12 +50,14 @@ export function Header() {
<HeaderNav />
<div className="flex shrink-0 items-center gap-2 md:gap-3">
<HeaderAccount />
{!session && (
<Link
href="/booking"
className="btn-mustard px-4 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] md:px-5"
>
Book
</Link>
)}
</div>
</div>
</div>

View File

@ -13,12 +13,8 @@ export function HeaderAccount() {
}
if (session) {
const points =
session.kind === "member" ? session.points : "—";
const label =
session.kind === "member"
? session.displayName.split(" ")[0] ?? "Guest"
: session.guestName.split(" ")[0] ?? "Guest";
const points = session.points;
const label = session.displayName.split(" ")[0] ?? "Guest";
return (
<div className="flex items-center gap-2 sm:gap-3">
@ -27,7 +23,7 @@ export function HeaderAccount() {
className="hidden max-w-[140px] truncate rounded-full border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-primary)] sm:inline-block"
title="Loyalty points"
>
{points !== "—" ? `${points} pts` : "Stay"}
{`${points} pts`}
</Link>
<Link
href="/guest/profile"

View File

@ -1,6 +1,6 @@
import Image from "next/image";
import Link from "next/link";
import type { Outlet } from "@/lib/mocks/outlets";
import type { Outlet } from "@/lib/data/outlets";
type Props = { outlet: Outlet };

View File

@ -13,8 +13,8 @@ import { createPortal } from "react-dom";
import {
bookingStyleReviews,
overallRatingOutOfFive,
} from "@/lib/mocks/bookingReviews";
import { siteConfig } from "@/lib/mocks/site";
} from "@/lib/data/bookingReviews";
import { siteConfig } from "@/lib/site-config";
function useIsClient() {
return useSyncExternalStore(

View File

@ -1,14 +1,23 @@
import Image from "next/image";
import Link from "next/link";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/lib/mocks/rooms";
import { RoomPrice } from "@/components/RoomPrice";
import type { Room } from "@/types/room";
type Props = { room: Room };
/** API rooms use UUID ids — deep links go to booking; static marketing rooms keep /rooms/[slug]. */
function roomPrimaryHref(room: Room): string {
if (/^[0-9a-f-]{36}$/i.test(room.id)) {
return `/booking?room=${encodeURIComponent(room.id)}`;
}
return `/rooms/${room.slug}`;
}
export function RoomCard({ room }: Props) {
const href = roomPrimaryHref(room);
return (
<article className="group card-lift flex flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm">
<Link href={`/rooms/${room.slug}`} className="relative aspect-[4/3] overflow-hidden">
<Link href={href} className="relative aspect-[4/3] overflow-hidden">
<Image
src={room.gallery[0]!}
alt={room.name}
@ -17,13 +26,13 @@ export function RoomCard({ room }: Props) {
sizes="(max-width:768px) 100vw, 33vw"
/>
<span className="absolute right-3 top-3 rounded-full bg-[var(--color-surface)]/90 px-3 py-1 text-xs font-semibold text-[var(--color-primary)] shadow-sm backdrop-blur">
From <FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={0} />
From <RoomPrice room={room} maximumFractionDigits={0} />
<span className="font-normal text-[var(--color-muted)]"> / night</span>
</span>
</Link>
<div className="flex flex-1 flex-col p-5 md:p-6">
<h3 className="font-heading text-xl text-[var(--color-text)] md:text-2xl">
<Link href={`/rooms/${room.slug}`} className="hover:text-[var(--color-primary)]">
<Link href={href} className="hover:text-[var(--color-primary)]">
{room.name}
</Link>
</h3>
@ -32,10 +41,10 @@ export function RoomCard({ room }: Props) {
</p>
<div className="mt-4 flex items-center justify-between gap-3">
<Link
href={`/rooms/${room.slug}`}
href={href}
className="text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline"
>
View details
{/^[0-9a-f-]{36}$/i.test(room.id) ? "Book this room" : "View details"}
</Link>
<Link
href={`/booking?room=${room.id}`}

View File

@ -0,0 +1,28 @@
"use client";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/types/room";
type Props = {
room: Pick<Room, "nightlyRate" | "priceCurrency">;
maximumFractionDigits?: 0 | 1 | 2;
className?: string;
};
export function RoomPrice({ room, maximumFractionDigits = 0, className }: Props) {
const cur = room.priceCurrency ?? "USD";
if (cur === "ETB") {
return (
<span className={className}>
{new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "ETB",
maximumFractionDigits,
}).format(room.nightlyRate)}
</span>
);
}
return (
<FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={maximumFractionDigits} className={className} />
);
}

View File

@ -3,9 +3,9 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/lib/mocks/rooms";
import { rooms } from "@/lib/mocks/rooms";
import { RoomPrice } from "@/components/RoomPrice";
import type { Room } from "@/types/room";
import { useBooking } from "@/context/BookingContext";
type Props = {
selected: Room | null;
@ -13,6 +13,7 @@ type Props = {
};
export function RoomSelectBooking({ selected, onSelect }: Props) {
const { rooms } = useBooking();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -32,7 +33,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left shadow-sm transition hover:border-[var(--color-primary)]/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
className="flex w-full items-center gap-3 rounded-2xl border border-(--color-border) bg-[var(--color-surface)] p-3 text-left shadow-sm transition hover:border-[var(--color-primary)]/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
aria-expanded={open}
aria-haspopup="listbox"
>
@ -40,7 +41,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
<>
<div className="relative h-14 w-20 shrink-0 overflow-hidden rounded-lg">
<Image
src={selected.gallery[0]!}
src={selected.gallery[0] || "/images/shitaye-logo.png"}
alt={selected.name}
fill
className="object-cover"
@ -50,12 +51,12 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
<div className="min-w-0 flex-1">
<p className="font-semibold text-[var(--color-text)]">{selected.name}</p>
<p className="text-xs text-[var(--color-muted)]">
From ${selected.nightlyRate} / night
From <RoomPrice room={selected} maximumFractionDigits={0} /> / night
</p>
</div>
</>
) : (
<span className="text-[var(--color-muted)]">Choose a room category</span>
<span className="text-[var(--color-muted)]">Choose a room</span>
)}
<span className="shrink-0 text-[var(--color-muted)]" aria-hidden>
{open ? "▴" : "▾"}
@ -79,7 +80,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
>
<div className="relative h-12 w-[4.5rem] shrink-0 overflow-hidden rounded-lg">
<Image
src={room.gallery[0]!}
src={room.gallery[0] || "/images/shitaye-logo.png"}
alt={room.name}
fill
className="object-cover"
@ -89,7 +90,7 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--color-text)]">{room.name}</p>
<p className="text-xs text-[var(--color-muted)]">
<FormattedUsd amountUsd={room.nightlyRate} maximumFractionDigits={0} />
<RoomPrice room={room} maximumFractionDigits={0} />
/night · max {room.maxGuests} guests
</p>
</div>
@ -101,10 +102,16 @@ export function RoomSelectBooking({ selected, onSelect }: Props) {
{selected ? (
<Link
href={`/rooms/${selected.slug}`}
href={
/^[0-9a-f-]{36}$/i.test(selected.id)
? `/booking?room=${encodeURIComponent(selected.id)}`
: `/rooms/${selected.slug}`
}
className="mt-2 inline-block text-sm font-semibold text-[var(--color-primary)] hover:underline"
>
View full room details & amenities
{/^[0-9a-f-]{36}$/i.test(selected.id)
? "Back to room selection"
: "View full room details & amenities"}
</Link>
) : null}
</div>

View File

@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import { useBookingStore } from "@/stores/booking-store";
import { useCurrencyStore } from "@/stores/currency-store";
import { useOrdersStore } from "@/stores/orders-store";
/** Rehydrate persisted stores and kick async loads (rooms) once on the client. */
export function StoreHydration() {
useEffect(() => {
void useCurrencyStore.persist.rehydrate();
void useBookingStore.getState().refreshRooms();
}, []);
return null;
}

View File

@ -1,4 +1,4 @@
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
import { Mock3DPlaceholder } from "./Mock3DPlaceholder";
import { VirtualTourEmbed } from "./VirtualTourEmbed";

View File

@ -1,16 +1,227 @@
/**
* Back-compat re-exports canonical implementation is GuestSessionContext.
*/
export {
GuestSessionProvider,
GuestSessionProvider as AuthProvider,
useAuth,
useGuestSession,
} from "./GuestSessionContext";
export type {
BookingRefSession,
GuestSession,
MemberSession,
OrderCategory,
OrderRecord,
} from "./GuestSessionContext";
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
type ReactNode,
} from "react";
import { signIn, signOut, useSession } from "next-auth/react";
import { getHotelPropertyId, getPublicApiUrl } from "@/lib/env";
import { useGuestUiStore } from "@/stores/guest-ui-store";
import { useOrdersStore } from "@/stores/orders-store";
import type { OrderRecord } from "@/types/guest-order";
export type { OrderCategory, OrderRecord } from "@/types/guest-order";
export type MemberSession = {
kind: "member";
accessToken: string;
email: string;
displayName: string;
propertyId?: string;
points: number;
authMethod: "otp" | "password" | "google";
bookingCode?: string | null;
bookingId?: string | null;
role?: string;
};
export type GuestSession = MemberSession;
type AuthContextValue = {
session: GuestSession | null;
orders: OrderRecord[];
isHydrated: boolean;
accessToken: string | null;
requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>;
verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>;
loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>;
loginGoogle: () => Promise<void>;
loginBookingRef: (ref: string) => Promise<{ ok: boolean; message: string }>;
logout: () => Promise<void>;
addOrder: (o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] }) => void;
awardPoints: (points: number) => void;
setOrders: (orders: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const { data, status } = useSession();
const orders = useOrdersStore((s) => s.orders);
const localBonusPoints = useGuestUiStore((s) => s.localBonusPoints);
const isHydrated = status !== "loading";
const guestSession = useMemo((): GuestSession | null => {
if (status !== "authenticated" || !data?.accessToken) return null;
const email = data.user?.email ?? "";
const displayName = data.user?.name ?? email.split("@")[0] ?? "Guest";
return {
kind: "member",
accessToken: data.accessToken,
email,
displayName: displayName.charAt(0).toUpperCase() + displayName.slice(1),
propertyId: data.propertyId ?? getHotelPropertyId(),
points: localBonusPoints,
authMethod: data.authMethod ?? "password",
bookingCode: data.bookingCode ?? null,
bookingId: data.bookingId ?? null,
role: data.role,
};
}, [status, data, localBonusPoints]);
const requestOtp = useCallback(async (email: string) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { ok: false, message: "Enter a valid email address." };
}
const propertyId = getHotelPropertyId();
if (!propertyId) {
return { ok: false, message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID)." };
}
try {
const base = getPublicApiUrl();
const res = await fetch(`${base}/auth/hotel-guest/send-otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ propertyId, email: email.trim().toLowerCase() }),
});
const body = (await res.json().catch(() => ({}))) as { message?: string };
if (!res.ok) {
return {
ok: false,
message: typeof body.message === "string" ? body.message : "Could not send code.",
};
}
return { ok: true, message: "Check your email for the one-time code." };
} catch {
return { ok: false, message: "Could not send code. Try again." };
}
}, []);
const verifyOtp = useCallback(async (email: string, code: string) => {
const r = await signIn("hotel-otp", {
email: email.trim().toLowerCase(),
otp: code.replace(/\s/g, ""),
redirect: false,
});
if (r?.error) {
return { ok: false, message: "Invalid or expired code." };
}
return { ok: true, message: "Signed in." };
}, []);
const loginPassword = useCallback(async (email: string, password: string) => {
if (!email || !password) {
return { ok: false, message: "Email and password required." };
}
const r = await signIn("credentials", {
identifier: email.trim().toLowerCase(),
password,
redirect: false,
});
if (r?.error) {
return { ok: false, message: "Invalid email or password." };
}
return { ok: true, message: "Signed in." };
}, []);
const loginGoogle = useCallback(async () => {
await signIn("google", { callbackUrl: "/", redirect: true });
}, []);
const loginBookingRef = useCallback(async (ref: string) => {
const propertyId = getHotelPropertyId();
if (!propertyId) {
return {
ok: false,
message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID).",
};
}
const r = await signIn("booking-code", {
bookingCode: ref.trim(),
propertyId,
redirect: false,
});
if (r?.error) {
return { ok: false, message: "Invalid booking code or account not linked." };
}
return { ok: true, message: "Signed in with your booking." };
}, []);
const logout = useCallback(async () => {
useGuestUiStore.getState().resetLocalBonus();
await signOut({ callbackUrl: "/" });
}, []);
const setOrders = useCallback((next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => {
useOrdersStore.getState().setOrders(next);
}, []);
const addOrder = useCallback(
(
o: Omit<OrderRecord, "id" | "placedAt" | "status"> & {
status?: OrderRecord["status"];
},
) => {
const rec: OrderRecord = {
...o,
id: `ord-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
placedAt: new Date().toISOString(),
status: o.status ?? "pending",
};
useOrdersStore.getState().pushOrder(rec);
if (guestSession?.kind === "member") {
const bonus = Math.min(150, Math.round(o.totalUsd * 2));
useGuestUiStore.getState().addLocalBonus(bonus);
}
},
[guestSession],
);
const awardPoints = useCallback((points: number) => {
useGuestUiStore.getState().addLocalBonus(points);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session: guestSession,
orders,
isHydrated,
accessToken: data?.accessToken ?? null,
requestOtp,
verifyOtp,
loginPassword,
loginGoogle,
loginBookingRef,
logout,
addOrder,
awardPoints,
setOrders,
}),
[
guestSession,
orders,
isHydrated,
data?.accessToken,
requestOtp,
verifyOtp,
loginPassword,
loginGoogle,
loginBookingRef,
logout,
addOrder,
awardPoints,
setOrders,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@ -1,218 +1,2 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Room } from "@/lib/mocks/rooms";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
export type GuestDetails = {
firstName: string;
lastName: string;
email: string;
phone: string;
/** Airline / PNR / booking reference */
flightBookingNumber: string;
/** Local arrival time (24h from time input) */
arrivalTime: string;
};
const defaultDates = () => {
const inD = new Date();
inD.setDate(inD.getDate() + 7);
const outD = new Date(inD);
outD.setDate(outD.getDate() + 3);
return {
checkIn: inD.toISOString().slice(0, 10),
checkOut: outD.toISOString().slice(0, 10),
};
};
function nightsBetween(checkIn: string, checkOut: string): number {
const a = new Date(checkIn).getTime();
const b = new Date(checkOut).getTime();
const n = Math.ceil((b - a) / (1000 * 60 * 60 * 24));
return Math.max(1, n);
}
type BookingContextValue = {
checkIn: string;
checkOut: string;
guests: number;
roomId: string | null;
guest: GuestDetails;
couponCode: string;
couponPercentOff: number;
holdReference: string | null;
/** True when guest chose “reserve now, pay later” (hold without payment yet) */
payLaterHold: boolean;
confirmationId: string | null;
paidAt: string | null;
setDates: (checkIn: string, checkOut: string) => void;
setGuests: (n: number) => void;
setRoomId: (id: string | null) => void;
setGuest: (g: Partial<GuestDetails>) => void;
setCouponCode: (code: string) => void;
applyCoupon: () => void;
setHoldReference: (ref: string | null) => void;
setPayLaterHold: (value: boolean) => void;
setConfirmation: (id: string | null, paidAt: string | null) => void;
resetBooking: () => void;
selectedRoom: Room | null;
nights: number;
subtotal: number;
taxAmount: number;
discountAmount: number;
total: number;
};
const BookingContext = createContext<BookingContextValue | null>(null);
const emptyGuest: GuestDetails = {
firstName: "",
lastName: "",
email: "",
phone: "",
flightBookingNumber: "",
arrivalTime: "",
};
export function BookingProvider({ children }: { children: ReactNode }) {
const d = defaultDates();
const [checkIn, setCheckIn] = useState(d.checkIn);
const [checkOut, setCheckOut] = useState(d.checkOut);
const [guests, setGuestsState] = useState(2);
const [roomId, setRoomIdState] = useState<string | null>(null);
const [guest, setGuestState] = useState<GuestDetails>({ ...emptyGuest });
const [couponCode, setCouponCodeState] = useState("");
const [couponPercentOff, setCouponPercentOff] = useState(0);
const [holdReference, setHoldReference] = useState<string | null>(null);
const [payLaterHold, setPayLaterHoldState] = useState(false);
const [confirmationId, setConfirmationId] = useState<string | null>(null);
const [paidAt, setPaidAt] = useState<string | null>(null);
const setDates = useCallback((ci: string, co: string) => {
setCheckIn(ci);
setCheckOut(co);
}, []);
const setGuests = useCallback((n: number) => {
setGuestsState(Math.min(12, Math.max(1, n)));
}, []);
const setRoomId = useCallback((id: string | null) => {
setRoomIdState(id);
}, []);
const setGuest = useCallback((g: Partial<GuestDetails>) => {
setGuestState((prev) => ({ ...prev, ...g }));
}, []);
const setCouponCode = useCallback((code: string) => {
setCouponCodeState(code);
setCouponPercentOff(0);
}, []);
const applyCoupon = useCallback(() => {
const c = couponCode.trim().toUpperCase();
if (c === "SHITAYE10") setCouponPercentOff(10);
else if (c === "WELCOME5") setCouponPercentOff(5);
else setCouponPercentOff(0);
}, [couponCode]);
const setPayLaterHold = useCallback((value: boolean) => {
setPayLaterHoldState(value);
}, []);
const setConfirmation = useCallback((id: string | null, at: string | null) => {
setConfirmationId(id);
setPaidAt(at);
if (id) setPayLaterHoldState(false);
}, []);
const resetBooking = useCallback(() => {
const nd = defaultDates();
setCheckIn(nd.checkIn);
setCheckOut(nd.checkOut);
setGuestsState(2);
setRoomIdState(null);
setGuestState({ ...emptyGuest });
setCouponCodeState("");
setCouponPercentOff(0);
setHoldReference(null);
setPayLaterHoldState(false);
setConfirmationId(null);
setPaidAt(null);
}, []);
const selectedRoom = useMemo(
() => rooms.find((r) => r.id === roomId) ?? null,
[roomId],
);
const nights = useMemo(
() => nightsBetween(checkIn, checkOut),
[checkIn, checkOut],
);
const subtotal = useMemo(() => {
if (!selectedRoom) return 0;
return selectedRoom.nightlyRate * nights;
}, [selectedRoom, nights]);
const discountAmount = useMemo(
() => Math.round(subtotal * (couponPercentOff / 100) * 100) / 100,
[subtotal, couponPercentOff],
);
const afterDiscount = Math.max(0, subtotal - discountAmount);
const taxAmount =
Math.round(afterDiscount * siteConfig.taxRate * 100) / 100;
const total = Math.round((afterDiscount + taxAmount) * 100) / 100;
const value: BookingContextValue = {
checkIn,
checkOut,
guests,
roomId,
guest,
couponCode,
couponPercentOff,
holdReference,
payLaterHold,
confirmationId,
paidAt,
setDates,
setGuests,
setRoomId,
setGuest,
setCouponCode,
applyCoupon,
setHoldReference,
setPayLaterHold,
setConfirmation,
resetBooking,
selectedRoom,
nights,
subtotal,
taxAmount,
discountAmount,
total,
};
return (
<BookingContext.Provider value={value}>{children}</BookingContext.Provider>
);
}
export function useBooking() {
const ctx = useContext(BookingContext);
if (!ctx) throw new Error("useBooking must be used within BookingProvider");
return ctx;
}
export type { GuestDetails, LastCreatedBooking, BookingView } from "@/stores/booking-store";
export { useBooking, useBookingStore } from "@/stores/booking-store";

View File

@ -1,87 +1 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useSyncExternalStore,
type ReactNode,
} from "react";
import {
type CurrencyCode,
convertFromUsd,
formatMoneyFromUsd,
isCurrencyCode,
} from "@/lib/currency";
const STORAGE_KEY = "shitaye-currency";
const CURRENCY_EVENT = "shitaye-currency-change";
type CurrencyContextValue = {
currency: CurrencyCode;
setCurrency: (c: CurrencyCode) => void;
formatUsd: (amountUsd: number, maximumFractionDigits?: 0 | 1 | 2) => string;
convertUsd: (amountUsd: number) => number;
};
const CurrencyContext = createContext<CurrencyContextValue | null>(null);
function readCurrency(): CurrencyCode {
if (typeof window === "undefined") return "USD";
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw && isCurrencyCode(raw)) return raw;
} catch {
/* ignore */
}
return "USD";
}
function subscribe(onChange: () => void) {
if (typeof window === "undefined") return () => {};
const handler = () => onChange();
window.addEventListener("storage", handler);
window.addEventListener(CURRENCY_EVENT, handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener(CURRENCY_EVENT, handler);
};
}
export function CurrencyProvider({ children }: { children: ReactNode }) {
const currency = useSyncExternalStore(
subscribe,
readCurrency,
() => "USD" as CurrencyCode,
) as CurrencyCode;
const setCurrency = useCallback((c: CurrencyCode) => {
try {
localStorage.setItem(STORAGE_KEY, c);
window.dispatchEvent(new Event(CURRENCY_EVENT));
} catch {
/* ignore */
}
}, []);
const value = useMemo((): CurrencyContextValue => {
return {
currency,
setCurrency,
formatUsd: (amountUsd, maximumFractionDigits = 2) =>
formatMoneyFromUsd(amountUsd, currency, maximumFractionDigits),
convertUsd: (amountUsd) => convertFromUsd(amountUsd, currency),
};
}, [currency, setCurrency]);
return (
<CurrencyContext.Provider value={value}>{children}</CurrencyContext.Provider>
);
}
export function useCurrency() {
const ctx = useContext(CurrencyContext);
if (!ctx) throw new Error("useCurrency must be used within CurrencyProvider");
return ctx;
}
export { useCurrency, useCurrencyStore } from "@/stores/currency-store";

View File

@ -1,269 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
loadOrders as repoLoadOrders,
loadSession as repoLoadSession,
saveOrders as repoSaveOrders,
saveSession as repoSaveSession,
} from "@/lib/guest/repository";
import type {
BookingRefSession,
GuestSession,
MemberSession,
OrderRecord,
} from "@/lib/guest/types";
import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData";
import { lookupGuestBooking } from "@/lib/mocks/guestBookings";
export type {
BookingRefSession,
GuestSession,
MemberSession,
OrderCategory,
OrderRecord,
} from "@/lib/guest/types";
type GuestSessionContextValue = {
session: GuestSession | null;
orders: OrderRecord[];
isHydrated: boolean;
requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>;
verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>;
loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>;
loginSocial: (provider: "google" | "apple" | "facebook") => void;
loginBookingRef: (ref: string, lastName?: string) => { ok: boolean; message: string };
logout: () => void;
addOrder: (
o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] },
) => void;
awardPoints: (points: number) => void;
};
const GuestSessionContext = createContext<GuestSessionContextValue | null>(null);
export function GuestSessionProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<GuestSession | null>(null);
const [orders, setOrders] = useState<OrderRecord[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setSession(repoLoadSession());
setOrders(repoLoadOrders());
setIsHydrated(true);
}, []);
const requestOtp = useCallback(async (email: string) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { ok: false, message: "Enter a valid email address." };
}
return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." };
}, []);
const verifyOtp = useCallback(async (email: string, code: string) => {
const trimmed = code.replace(/\s/g, "");
if (trimmed !== "123456") {
return { ok: false, message: "Invalid code. Demo OTP is 123456." };
}
const local = email.split("@")[0] ?? "Guest";
const name = local.charAt(0).toUpperCase() + local.slice(1);
const next: MemberSession = {
kind: "member",
email: email.toLowerCase(),
displayName: name,
points: 2400,
tier: "Gold",
authMethod: "otp",
};
setSession(next);
repoSaveSession(next);
return { ok: true, message: "Signed in." };
}, []);
const loginPassword = useCallback(async (email: string, password: string) => {
if (!email || !password) {
return { ok: false, message: "Email and password required." };
}
if (password !== "shitaye" && password !== "demo123") {
return {
ok: false,
message: "Incorrect password. Try demo password: shitaye",
};
}
const local = email.split("@")[0] ?? "Guest";
const name = local.charAt(0).toUpperCase() + local.slice(1);
const next: MemberSession = {
kind: "member",
email: email.toLowerCase(),
displayName: name,
points: 2400,
tier: "Gold",
authMethod: "password",
};
setSession(next);
repoSaveSession(next);
return { ok: true, message: "Signed in." };
}, []);
const loginSocial = useCallback((provider: "google" | "apple" | "facebook") => {
const names: Record<typeof provider, string> = {
google: "Google Guest",
apple: "Apple Guest",
facebook: "Facebook Guest",
};
const next: MemberSession = {
kind: "member",
email: `guest.${provider}@shitaye.demo`,
displayName: names[provider],
points: 2100,
tier: "Silver",
authMethod: provider,
};
setSession(next);
repoSaveSession(next);
}, []);
const loginBookingRef = useCallback((refRaw: string, lastName?: string) => {
const key = refRaw.trim().toUpperCase();
if (key.startsWith("PAY-") || key.startsWith("SHY-")) {
if (!lastName?.trim()) {
return {
ok: false,
message: "Last name is required for confirmation / hold references.",
};
}
const row = lookupGuestBooking(refRaw, lastName);
if (!row) {
return {
ok: false,
message:
"Reference and last name do not match. Try PAY-MOCK-CONFIRMED + last name Demo, or SHY-MOCK-HOLD + Hold.",
};
}
const next: BookingRefSession = {
kind: "bookingRef",
bookingRef: row.ref,
guestName: row.guestName,
roomLabel: row.roomLabel,
checkOut: row.checkOut,
};
setSession(next);
repoSaveSession(next);
return { ok: true, message: "Linked to your stay." };
}
const legacy = DEMO_BOOKING_REFS[key];
if (!legacy) {
return {
ok: false,
message:
"Reference not found. Use SHITAYE-2026-DEMO, GUEST-1234, or PAY-MOCK-CONFIRMED with last name Demo.",
};
}
const next: BookingRefSession = {
kind: "bookingRef",
bookingRef: key,
guestName: legacy.guestName,
roomLabel: legacy.room,
checkOut: legacy.checkOut,
};
setSession(next);
repoSaveSession(next);
return { ok: true, message: "Linked to your stay." };
}, []);
const logout = useCallback(() => {
setSession(null);
repoSaveSession(null);
}, []);
const addOrder = useCallback(
(
o: Omit<OrderRecord, "id" | "placedAt" | "status"> & {
status?: OrderRecord["status"];
},
) => {
const rec: OrderRecord = {
...o,
id: `ord-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
placedAt: new Date().toISOString(),
status: o.status ?? "pending",
};
setOrders((prev) => {
const next = [rec, ...prev];
repoSaveOrders(next);
return next;
});
setSession((s) => {
if (!s || s.kind !== "member") return s;
const bonus = Math.min(150, Math.round(o.totalUsd * 2));
const u = { ...s, points: s.points + bonus };
repoSaveSession(u);
return u;
});
},
[],
);
const awardPoints = useCallback((points: number) => {
setSession((s) => {
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + points };
repoSaveSession(u);
return u;
});
}, []);
const value = useMemo<GuestSessionContextValue>(
() => ({
session,
orders,
isHydrated,
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
logout,
addOrder,
awardPoints,
}),
[
session,
orders,
isHydrated,
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
logout,
addOrder,
awardPoints,
],
);
return (
<GuestSessionContext.Provider value={value}>{children}</GuestSessionContext.Provider>
);
}
export function useGuestSession() {
const ctx = useContext(GuestSessionContext);
if (!ctx) throw new Error("useGuestSession must be used within GuestSessionProvider");
return ctx;
}
/** @deprecated Prefer useGuestSession — alias kept for existing imports */
export function useAuth() {
return useGuestSession();
}

45
src/lib/api-client.ts Normal file
View File

@ -0,0 +1,45 @@
import { getPublicApiUrl } from "@/lib/env";
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public body?: unknown,
) {
super(message);
this.name = "ApiError";
}
}
export async function apiFetch<T = unknown>(
path: string,
init: RequestInit & { accessToken?: string } = {},
): Promise<T> {
const base = getPublicApiUrl();
const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
const headers = new Headers(init.headers);
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (init.accessToken) {
headers.set("Authorization", `Bearer ${init.accessToken}`);
}
const res = await fetch(url, { ...init, headers });
const text = await res.text();
let data: unknown = null;
if (text) {
try {
data = JSON.parse(text) as unknown;
} catch {
data = text;
}
}
if (!res.ok) {
const msg =
typeof data === "object" && data !== null && "message" in data
? String((data as { message: unknown }).message)
: res.statusText;
throw new ApiError(msg || `HTTP ${res.status}`, res.status, data);
}
return data as T;
}

213
src/lib/auth-options.ts Normal file
View File

@ -0,0 +1,213 @@
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import { getPublicApiUrl, getHotelPropertyId } from "@/lib/env";
async function postJson<T>(path: string, body: Record<string, unknown>): Promise<T> {
const api = getPublicApiUrl();
const res = await fetch(`${api}${path.startsWith("/") ? path : `/${path}`}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json().catch(() => ({}))) as T & { message?: string };
if (!res.ok) {
throw new Error(
typeof data === "object" && data && "message" in data && typeof data.message === "string"
? data.message
: `Auth failed (${res.status})`,
);
}
return data as T;
}
type LoginPayload = {
access_token: string;
user: {
id: string;
email: string | null;
name: string | null;
role: string;
propertyId?: string;
};
};
type AuthMethod = "otp" | "password" | "google";
type BookingCodePayload = LoginPayload & {
booking?: {
id: string;
bookingCode: string | null;
checkIn: string;
checkOut: string;
status: string;
};
};
const providers: NextAuthOptions["providers"] = [
CredentialsProvider({
id: "credentials",
name: "Email & password",
credentials: {
identifier: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.identifier || !credentials?.password) return null;
try {
const data = await postJson<LoginPayload>("/auth/login", {
identifier: credentials.identifier,
password: credentials.password,
});
return {
id: data.user.id,
email: data.user.email ?? undefined,
name: data.user.name ?? undefined,
accessToken: data.access_token,
role: data.user.role,
propertyId: data.user.propertyId,
authMethod: "password" as AuthMethod,
};
} catch {
return null;
}
},
}),
CredentialsProvider({
id: "hotel-otp",
name: "Hotel email OTP",
credentials: {
email: { label: "Email", type: "text" },
otp: { label: "Code", type: "text" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.otp) return null;
try {
const data = await postJson<LoginPayload>("/auth/hotel-user/login-email-otp", {
email: credentials.email,
otp: credentials.otp,
});
return {
id: data.user.id,
email: data.user.email ?? undefined,
name: data.user.name ?? undefined,
accessToken: data.access_token,
role: data.user.role,
propertyId: data.user.propertyId,
authMethod: "otp" as AuthMethod,
};
} catch {
return null;
}
},
}),
CredentialsProvider({
id: "booking-code",
name: "Booking code",
credentials: {
bookingCode: { label: "Booking code", type: "text" },
propertyId: { label: "Property", type: "text" },
},
async authorize(credentials) {
const propertyId = credentials?.propertyId?.trim() || getHotelPropertyId();
const bookingCode = credentials?.bookingCode?.trim();
if (!propertyId || !bookingCode) return null;
try {
const data = await postJson<BookingCodePayload>("/auth/hotel-guest/login-booking-code", {
propertyId,
bookingCode,
});
return {
id: data.user.id,
email: data.user.email ?? undefined,
name: data.user.name ?? undefined,
accessToken: data.access_token,
role: data.user.role,
propertyId: data.user.propertyId ?? propertyId,
bookingCode: data.booking?.bookingCode ?? bookingCode,
bookingId: data.booking?.id ?? null,
};
} catch {
return null;
}
},
}),
];
if (process.env.GOOGLE_CLIENT_ID?.trim() && process.env.GOOGLE_CLIENT_SECRET?.trim()) {
providers.push(
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
);
}
export const authOptions: NextAuthOptions = {
providers,
callbacks: {
async jwt({ token, user, account, profile }) {
if (account?.provider === "google" && profile && "email" in profile && profile.email) {
try {
const data = await postJson<LoginPayload>("/auth/google", {
email: profile.email,
name: profile.name,
googleId: profile.sub,
role: "CUSTOMER",
});
token.accessToken = data.access_token;
token.sub = data.user.id;
token.email = data.user.email ?? undefined;
token.name = data.user.name ?? undefined;
token.role = data.user.role;
token.propertyId = data.user.propertyId;
token.authMethod = "google";
token.error = undefined;
} catch {
token.error = "GoogleSignInFailed";
}
return token;
}
if (user) {
const u = user as {
accessToken?: string;
role?: string;
propertyId?: string;
bookingCode?: string | null;
bookingId?: string | null;
authMethod?: AuthMethod;
};
token.accessToken = u.accessToken;
token.role = u.role;
token.propertyId = u.propertyId;
token.bookingCode = u.bookingCode ?? undefined;
token.bookingId = u.bookingId ?? undefined;
token.authMethod = u.authMethod ?? token.authMethod ?? "password";
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.email = (token.email as string) ?? session.user.email;
session.user.name = (token.name as string) ?? session.user.name;
}
session.accessToken = token.accessToken as string | undefined;
session.role = token.role as string | undefined;
session.propertyId = (token.propertyId as string | undefined) ?? getHotelPropertyId();
session.bookingCode = token.bookingCode ?? null;
session.bookingId = token.bookingId ?? null;
session.authMethod = (token.authMethod as AuthMethod | undefined) ?? "password";
session.error = token.error as string | undefined;
return session;
},
},
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
maxAge: 60 * 60 * 24 * 7,
},
secret: process.env.NEXTAUTH_SECRET,
};

View File

@ -1,14 +1,15 @@
export type CurrencyCode = "USD" | "EUR" | "GBP" | "AED";
export type CurrencyCode = "USD" | "EUR" | "GBP" | "AED" | "ETB";
export const CURRENCY_OPTIONS: { code: CurrencyCode; shortLabel: string }[] = [
{ code: "ETB", shortLabel: "ETB" },
{ code: "USD", shortLabel: "USD" },
{ code: "EUR", shortLabel: "EUR" },
{ code: "GBP", shortLabel: "GBP" },
{ code: "AED", shortLabel: "AED" },
];
/** Display amount = catalog USD × rate (illustrative mock rates). */
export const USD_TO: Record<CurrencyCode, number> = {
ETB: 1,
USD: 1,
EUR: 0.93,
GBP: 0.79,
@ -16,7 +17,7 @@ export const USD_TO: Record<CurrencyCode, number> = {
};
export function isCurrencyCode(v: string): v is CurrencyCode {
return v === "USD" || v === "EUR" || v === "GBP" || v === "AED";
return v === "USD" || v === "EUR" || v === "GBP" || v === "AED" || v === "ETB";
}
export function convertFromUsd(usd: number, code: CurrencyCode): number {

View File

@ -1,20 +1,3 @@
/** Demo booking references — any guest can use these in mock mode. */
export const DEMO_BOOKING_REFS: Record<
string,
{ guestName: string; room: string; checkOut: string }
> = {
"SHITAYE-2026-DEMO": {
guestName: "Demo Guest",
room: "Junior Studio · 1204",
checkOut: "2026-04-12",
},
"GUEST-1234": {
guestName: "Abebe T.",
room: "Standard King · 805",
checkOut: "2026-04-09",
},
};
export type MockAppointment = {
id: string;
title: string;

View File

@ -0,0 +1,34 @@
export type LaundryCartItem = {
label: string;
quantity: number;
};
export const laundryItems = [
{
label: "Shirt / blouse",
price: 50, // ETB
},
{
label: "Pants / trousers",
price: 60, // ETB
},
{
label: "Suit (2 pc)",
price: 120, // ETB
},
{
label: "Dress",
price: 80, // ETB
},
{
label: "Jacket",
price: 70, // ETB
},
] as const;
export const SAME_DAY_SURCHARGE = 100; // ETB per order
export const laundryPrices: Record<string, number> = Object.fromEntries(
laundryItems.map(({ label, price }) => [label.toLowerCase(), price])
);

View File

@ -1,20 +1,6 @@
export type Room = {
id: string;
slug: string;
name: string;
shortDescription: string;
longDescription: string;
nightlyRate: number;
maxGuests: number;
beds: string;
sizeSqM: number;
view: string;
highlights: string[];
gallery: string[];
tourEmbedUrl: string | null;
};
import type { Room } from "@/types/room";
export const rooms: Room[] = [
const marketingRooms: Room[] = [
{
id: "penthouse",
slug: "four-bedroom-penthouse",
@ -23,6 +9,7 @@ export const rooms: Room[] = [
longDescription:
"Experience elevated living in our four-bedroom penthouse — expansive layouts, state-of-the-art kitchenette, and amazing views over Addis Ababa. Ideal for extended stays and distinguished guests who expect space, privacy, and premium finishes.",
nightlyRate: 485,
priceCurrency: "USD",
maxGuests: 8,
beds: "4 bedrooms — mix of king and twin configurations",
sizeSqM: 220,
@ -41,8 +28,9 @@ export const rooms: Room[] = [
name: "Standard Rooms",
shortDescription: "Refined comfort with every essential amenity.",
longDescription:
"Our standard rooms combine restful design with practical luxury: premium bedding, dedicated workspace, IPTV, and seamless WiFi / LAN. Perfect for business and leisure travellers who value consistency and calm.",
"Our standard rooms combine restful design with practical luxury: premium bedding, dedicated workspace, IPTV, and seamless Wi-Fi / LAN. Perfect for business and leisure travellers who value consistency and calm.",
nightlyRate: 120,
priceCurrency: "USD",
maxGuests: 2,
beds: "1 King or 2 Twin",
sizeSqM: 28,
@ -62,6 +50,7 @@ export const rooms: Room[] = [
longDescription:
"Connecting suite rooms with the option of converting to family suites. Enjoy separate living and sleeping zones, kitchenette access where applicable, and the same premium amenities found across the property.",
nightlyRate: 210,
priceCurrency: "USD",
maxGuests: 5,
beds: "1 King + connecting twin room",
sizeSqM: 55,
@ -79,8 +68,9 @@ export const rooms: Room[] = [
name: "Junior Studios",
shortDescription: "Compact sophistication for solo travellers and short stays.",
longDescription:
"Junior studios offer a smart open plan with kitchenette, premium WiFi, IPTV, and efficient storage — designed for guests who want independence without sacrificing hotel service.",
"Junior studios offer a smart open plan with kitchenette, premium Wi-Fi, IPTV, and efficient storage — designed for guests who want independence without sacrificing hotel service.",
nightlyRate: 95,
priceCurrency: "USD",
maxGuests: 2,
beds: "1 Queen",
sizeSqM: 32,
@ -94,10 +84,10 @@ export const rooms: Room[] = [
},
];
export function getRoomBySlug(slug: string): Room | undefined {
return rooms.find((r) => r.slug === slug);
export function getMarketingRoomBySlug(slug: string): Room | undefined {
return marketingRooms.find((room) => room.slug === slug);
}
export function getAllRoomSlugs(): string[] {
return rooms.map((r) => r.slug);
export function getAllMarketingRoomSlugs(): string[] {
return marketingRooms.map((room) => room.slug);
}

10
src/lib/env.ts Normal file
View File

@ -0,0 +1,10 @@
export function getPublicApiUrl(): string {
const u = process.env.NEXT_PUBLIC_API_URL?.trim();
if (u) return u.replace(/\/$/, "");
return "http://localhost:7777/api";
}
export function getHotelPropertyId(): string | undefined {
const id = process.env.NEXT_PUBLIC_HOTEL_PROPERTY_ID?.trim();
return id || undefined;
}

7
src/lib/format-etb.ts Normal file
View File

@ -0,0 +1,7 @@
export function formatEtb(amount: number, maximumFractionDigits: 0 | 1 | 2 = 2): string {
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "ETB",
maximumFractionDigits,
}).format(amount);
}

309
src/lib/guest-hotel-api.ts Normal file
View File

@ -0,0 +1,309 @@
import { apiFetch } from "@/lib/api-client";
export type GuestMeResponse = {
customer: {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
};
balance: number;
};
export async function guestMe(propertyId: string, accessToken: string): Promise<GuestMeResponse> {
return apiFetch<GuestMeResponse>(`/properties/${propertyId}/hotel/guest/me`, {
method: "GET",
accessToken,
});
}
export type PointLedgerRow = {
id: string;
delta: number;
reason: string;
createdAt: string;
sourceKey?: string | null;
};
export async function guestPointsHistory(
propertyId: string,
accessToken: string,
): Promise<{ data: PointLedgerRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/points/history`, {
method: "GET",
accessToken,
});
}
export type GuestBookingRow = {
id: string;
bookingCode: string | null;
checkIn: string;
checkOut: string;
status: string;
guestCount: number;
totalPrice: string | number | null;
currency: string;
payLaterHold: boolean;
room?: { id: string; name: string; roomType: string; baseRate?: string | number };
};
export async function guestBookings(propertyId: string, accessToken: string) {
return apiFetch<{ data: GuestBookingRow[] }>(`/properties/${propertyId}/hotel/guest/bookings`, {
method: "GET",
accessToken,
});
}
export type MenuItemRow = {
id: string;
image: string;
name: string;
unitPrice: string | number;
isAvailable: boolean;
category?: string | null;
description?: string | null;
};
export async function guestMenuItems(propertyId: string, accessToken: string) {
return apiFetch<{ data: MenuItemRow[] }>(`/properties/${propertyId}/hotel/guest/menu/items`, {
method: "GET",
accessToken,
});
}
export async function guestPlaceRoomService(
propertyId: string,
accessToken: string,
body: {
bookingId: string;
notes?: string;
lines: { menuItemId: string; quantity: number }[];
},
) {
return apiFetch(`/properties/${propertyId}/hotel/guest/room-service/orders`, {
method: "POST",
accessToken,
body: JSON.stringify(body),
});
}
export async function guestPlaceLaundry(
propertyId: string,
accessToken: string,
body: {
bookingId: string;
items: unknown;
pickupAt?: string;
deliverAt?: string;
notes?: string;
total?: string | number;
sameDay?: boolean;
},
) {
return apiFetch(`/properties/${propertyId}/hotel/guest/laundry`, {
method: "POST",
accessToken,
body: JSON.stringify(body),
});
}
export type RoomServiceOrderRow = {
id: string;
bookingId: string;
status: string; // e.g. "PENDING"
total: string; // e.g. "3000"
currency: string; // e.g. "ETB"
notes?: string | null;
createdAt: string;
lines: {
id: string;
quantity: number;
menuItem?: {
id: string;
name: string;
category?: string;
} | null;
}[];
};
export async function guestRoomServiceOrders(
propertyId: string,
accessToken: string,
): Promise<{ data: RoomServiceOrderRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/room-service/orders`, {
method: "GET",
accessToken,
});
}
export type LaundryOrderRow = {
id: string;
bookingId: string;
status: string;
total: string | null;
notes?: string | null;
createdAt: string;
};
export async function guestLaundryOrders(
propertyId: string,
accessToken: string,
): Promise<{ data: LaundryOrderRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/laundry`, {
method: "GET",
accessToken,
});
}
export type SpaOfferingRow = {
id: string;
image: string;
kind: "SPA_SESSION" | "SPA_PACKAGE" | "GYM_PASS";
name: string;
description?: string | null;
price: string | number;
durationMinutes?: number | null;
};
export async function guestSpaOfferings(
propertyId: string,
accessToken: string,
): Promise<{ data: SpaOfferingRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/spa/offerings`, {
method: "GET",
accessToken,
});
}
export type SpaBookingRow = {
id: string;
bookingId: string;
offeringId: string;
status: string;
total: string | number;
scheduledAt?: string | null;
createdAt: string;
offering?: {
id: string;
name: string;
kind: "SPA_SESSION" | "SPA_PACKAGE" | "GYM_PASS";
} | null;
};
export async function guestSpaBookings(
propertyId: string,
accessToken: string,
): Promise<{ data: SpaBookingRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/spa/bookings`, {
method: "GET",
accessToken,
});
}
export async function guestCreateSpaBooking(
propertyId: string,
accessToken: string,
body: { bookingId: string; offeringId: string; scheduledAt?: string },
) {
return apiFetch(`/properties/${propertyId}/hotel/guest/spa/bookings`, {
method: "POST",
accessToken,
body: JSON.stringify(body),
});
}
export type UnifiedGuestOrder = {
id: string;
type: "room-service" | "laundry" | "spa" | "gym";
status: string;
total: number;
currency: string;
createdAt: string;
detail: string;
};
export async function guestOrders(
propertyId: string,
accessToken: string,
filters?: { type?: "room-service" | "laundry" | "spa" | "gym"; status?: string },
): Promise<{ data: UnifiedGuestOrder[] }> {
const query = new URLSearchParams();
if (filters?.type) query.set("type", filters.type);
if (filters?.status) query.set("status", filters.status);
const suffix = query.toString() ? `?${query.toString()}` : "";
try {
return await apiFetch<{ data: UnifiedGuestOrder[] }>(
`/properties/${propertyId}/hotel/guest/orders${suffix}`,
{
method: "GET",
accessToken,
},
);
} catch {
const [rs, laundry, spa] = await Promise.all([
guestRoomServiceOrders(propertyId, accessToken),
guestLaundryOrders(propertyId, accessToken),
guestSpaBookings(propertyId, accessToken),
]);
const mapped: UnifiedGuestOrder[] = [
...(rs.data ?? []).map((o) => ({
id: o.id,
type: "room-service" as const,
status: o.status,
total: Number(o.total ?? 0),
currency: o.currency ?? "ETB",
createdAt: o.createdAt,
detail: o.lines.map((l) => `${l.quantity}x ${l.menuItem?.name ?? "Item"}`).join(", "),
})),
...(laundry.data ?? []).map((o) => ({
id: o.id,
type: "laundry" as const,
status: o.status,
total: Number(o.total ?? 0),
currency: "ETB",
createdAt: o.createdAt,
detail: o.notes ?? "Laundry request",
})),
...(spa.data ?? []).map((o) => ({
id: o.id,
type: o.offering?.kind === "GYM_PASS" ? ("gym" as const) : ("spa" as const),
status: o.status,
total: Number(o.total ?? 0),
currency: "ETB",
createdAt: o.createdAt,
detail: o.offering?.name ?? "Spa/Gym booking",
})),
];
const filtered = mapped.filter((r) => {
if (filters?.type && r.type !== filters.type) return false;
if (filters?.status && r.status.toLowerCase() !== filters.status.toLowerCase()) return false;
return true;
});
return { data: filtered.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)) };
}
}
export type ShuttleRow = {
id: string;
bookingId: string;
direction: string;
requestedAt: string;
flightRef: string | null;
notes: string | null;
status: string;
createdAt: string;
updatedAt: string;
};
export async function guestShuttles(
propertyId: string,
bookingId: string,
accessToken: string,
): Promise<{ data: ShuttleRow[] }> {
return apiFetch(`/properties/${propertyId}/hotel/guest/bookings/${bookingId}/shuttles`, {
method: "GET",
accessToken,
});
}

View File

@ -1,41 +0,0 @@
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type BookingPayload = {
roomId: string;
email: string;
flightBookingNumber: string;
arrivalTime: string;
};
export type PaymentPayload = {
totalCents: number;
last4?: string;
};
export async function submitBookingHold(
payload: BookingPayload,
): Promise<{ reference: string }> {
void payload;
await delay(900 + Math.random() * 400);
return {
reference: `SHY-${Date.now().toString(36).toUpperCase()}`,
};
}
export async function processPayment(
payload: PaymentPayload,
): Promise<{ confirmationId: string; paidAt: string }> {
void payload;
await delay(1100 + Math.random() * 500);
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID().slice(0, 8)
: Math.random().toString(36).slice(2, 10);
const confirmationId = `PAY-${id.toUpperCase()}`;
return {
confirmationId,
paidAt: new Date().toISOString(),
};
}

View File

@ -1,45 +0,0 @@
export type LaundryItem = {
id: string;
name: string;
description: string;
priceUsd: number;
unit: string;
};
export const laundryItems: LaundryItem[] = [
{
id: "l-1",
name: "Shirt / blouse",
description: "Pressed",
priceUsd: 4,
unit: "each",
},
{
id: "l-2",
name: "Trousers / skirt",
description: "Pressed",
priceUsd: 5,
unit: "each",
},
{
id: "l-3",
name: "Suit (2 pc)",
description: "Clean & press",
priceUsd: 18,
unit: "set",
},
{
id: "l-4",
name: "Dress",
description: "Delicate cycle",
priceUsd: 12,
unit: "each",
},
{
id: "l-5",
name: "Express (same day)",
description: "Surcharge on top of item prices",
priceUsd: 15,
unit: "per order",
},
];

View File

@ -0,0 +1,65 @@
import { apiFetch } from "@/lib/api-client";
import { getHotelPropertyId } from "@/lib/env";
/** Matches `HotelPublicService.listRooms` select — Nest serializes `Decimal` as string. */
export type HotelPublicRoom = {
id: string;
name: string;
roomType: string;
maxGuests: number;
baseRate: string | number;
imageKeys: string[];
operationalStatus?: string;
};
export async function fetchPublicRooms(propertyId: string): Promise<HotelPublicRoom[]> {
const res = await apiFetch<{ data: HotelPublicRoom[] }>(
`/properties/${propertyId}/hotel/public/rooms`,
{ method: "GET" },
);
return res.data ?? [];
}
export type PublicBookingResponse = {
id: string;
bookingCode: string | null;
totalPrice: string | number | null;
currency: string;
checkIn: string;
checkOut: string;
status: string;
payLaterHold: boolean;
room?: { id: string; name: string; roomType: string };
};
export async function createPublicBooking(
propertyId: string,
body: {
roomId: string;
checkIn: string;
checkOut: string;
guestCount?: number;
firstName: string;
lastName: string;
email: string;
phone?: string;
flightPnr?: string;
arrivalTime?: string;
discountCode?: string;
referralCode?: string;
payLaterHold?: boolean;
},
): Promise<PublicBookingResponse> {
return apiFetch<PublicBookingResponse>(`/properties/${propertyId}/hotel/public/bookings`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function ensurePropertyId(): Promise<string> {
const id = getHotelPropertyId();
if (!id) {
throw new Error("Set NEXT_PUBLIC_HOTEL_PROPERTY_ID for the hotel client");
}
return id;
}

46
src/lib/room-mapper.ts Normal file
View File

@ -0,0 +1,46 @@
import type { Room } from "@/types/room";
import type { HotelPublicRoom } from "@/lib/public-hotel-api";
const PLACEHOLDER =
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80&auto=format&fit=crop";
function slugify(name: string, id: string): string {
const base = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return base || id.slice(0, 8);
}
export function mapApiRoomToRoom(api: HotelPublicRoom): Room {
const nightlyRate =
typeof api.baseRate === "string" ? Number.parseFloat(api.baseRate) : api.baseRate;
const origin = process.env.NEXT_PUBLIC_MEDIA_ORIGIN?.replace(/\/$/, "") ?? "";
const gallery =
api.imageKeys?.length > 0
? api.imageKeys.map((k) => {
if (k.startsWith("http")) return k;
if (origin) return `${origin}/${k.replace(/^\//, "")}`;
return PLACEHOLDER;
})
: [PLACEHOLDER];
const slug = slugify(api.name, api.id);
return {
id: api.id,
slug,
name: api.name,
shortDescription: api.roomType,
longDescription: `${api.name}${api.roomType}. Max ${api.maxGuests} guests.`,
nightlyRate: Number.isFinite(nightlyRate) ? nightlyRate : 0,
maxGuests: api.maxGuests,
beds: api.roomType,
sizeSqM: 0,
view: "",
highlights: [],
gallery: gallery.filter(Boolean).length ? gallery : [PLACEHOLDER],
tourEmbedUrl: null,
priceCurrency: "ETB",
};
}

View File

@ -1,4 +1,4 @@
/** Site-wide mock config — replace embed URLs when real Matterport/360 tours exist. */
/** Site-wide static config until CMS / API-backed content exists. */
export const siteConfig = {
name: "Shitaye Suite Hotel",
tagline: "The Unwinding Choice",

View File

@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { guestBookings } from "@/lib/guest-hotel-api";
const ACTIVE = new Set(["HOLD", "CONFIRMED", "CHECKED_IN"]);
export function useGuestActiveBooking() {
const { session, accessToken, isHydrated } = useAuth();
const propertyId = session?.kind === "member" ? session.propertyId : undefined;
const fromSession = session?.kind === "member" ? (session.bookingId ?? null) : null;
const [bookingId, setBookingId] = useState<string | null>(fromSession);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isHydrated) return;
if (session?.kind !== "member") {
setBookingId(null);
return;
}
if (fromSession) {
setBookingId(fromSession);
return;
}
if (!accessToken || !propertyId) {
setBookingId(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
const { data } = await guestBookings(propertyId, accessToken);
const row = data?.find((b) => ACTIVE.has(b.status));
if (!cancelled) setBookingId(row?.id ?? null);
} catch {
if (!cancelled) setBookingId(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [isHydrated, session?.kind, fromSession, accessToken, propertyId]);
return { bookingId, loading, propertyId };
}

262
src/stores/booking-store.ts Normal file
View File

@ -0,0 +1,262 @@
import { useMemo } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
import { getHotelPropertyId } from "@/lib/env";
import { fetchPublicRooms } from "@/lib/public-hotel-api";
import { mapApiRoomToRoom } from "@/lib/room-mapper";
import { siteConfig } from "@/lib/site-config";
import type { Room } from "@/types/room";
export type GuestDetails = {
firstName: string;
lastName: string;
email: string;
phone: string;
flightBookingNumber: string;
arrivalTime: string;
};
export type LastCreatedBooking = {
id: string;
bookingCode: string | null;
totalPrice: number;
currency: string;
};
function defaultDates() {
const inD = new Date();
inD.setDate(inD.getDate() + 7);
const outD = new Date(inD);
outD.setDate(outD.getDate() + 3);
return {
checkIn: inD.toISOString().slice(0, 10),
checkOut: outD.toISOString().slice(0, 10),
};
}
function nightsBetween(checkIn: string, checkOut: string): number {
const a = new Date(checkIn).getTime();
const b = new Date(checkOut).getTime();
const n = Math.ceil((b - a) / (1000 * 60 * 60 * 24));
return Math.max(1, n);
}
const emptyGuest: GuestDetails = {
firstName: "",
lastName: "",
email: "",
phone: "",
flightBookingNumber: "",
arrivalTime: "",
};
type BookingState = {
checkIn: string;
checkOut: string;
guests: number;
roomId: string | null;
guest: GuestDetails;
couponCode: string;
couponPercentOff: number;
holdReference: string | null;
payLaterHold: boolean;
confirmationId: string | null;
paidAt: string | null;
rooms: Room[];
roomsLoading: boolean;
roomsError: string | null;
lastCreatedBooking: LastCreatedBooking | null;
setDates: (checkIn: string, checkOut: string) => void;
setGuests: (n: number) => void;
setRoomId: (id: string | null) => void;
setGuest: (g: Partial<GuestDetails>) => void;
setCouponCode: (code: string) => void;
applyCoupon: () => void;
setHoldReference: (ref: string | null) => void;
setPayLaterHold: (value: boolean) => void;
setConfirmation: (id: string | null, at: string | null) => void;
setLastCreatedBooking: (b: LastCreatedBooking | null) => void;
resetBooking: () => void;
refreshRooms: () => Promise<void>;
};
export type BookingSnapshot = Omit<
BookingState,
| "setDates"
| "setGuests"
| "setRoomId"
| "setGuest"
| "setCouponCode"
| "applyCoupon"
| "setHoldReference"
| "setPayLaterHold"
| "setConfirmation"
| "setLastCreatedBooking"
| "resetBooking"
| "refreshRooms"
>;
export type BookingView = BookingSnapshot & {
selectedRoom: Room | null;
nights: number;
subtotal: number;
taxAmount: number;
discountAmount: number;
total: number;
} & Pick<
BookingState,
| "setDates"
| "setGuests"
| "setRoomId"
| "setGuest"
| "setCouponCode"
| "applyCoupon"
| "setHoldReference"
| "setPayLaterHold"
| "setConfirmation"
| "setLastCreatedBooking"
| "resetBooking"
| "refreshRooms"
>;
const d0 = defaultDates();
export const useBookingStore = create<BookingState>()((set, get) => ({
checkIn: d0.checkIn,
checkOut: d0.checkOut,
guests: 1,
roomId: null,
guest: { ...emptyGuest },
couponCode: "",
couponPercentOff: 0,
holdReference: null,
payLaterHold: false,
confirmationId: null,
paidAt: null,
rooms: [],
roomsLoading: false,
roomsError: null,
lastCreatedBooking: null,
setDates: (checkIn, checkOut) => set({ checkIn, checkOut }),
setGuests: (n) => set({ guests: Math.min(12, Math.max(1, n)) }),
setRoomId: (roomId) => set({ roomId }),
setGuest: (g) => set((s) => ({ guest: { ...s.guest, ...g } })),
setCouponCode: (code) => set({ couponCode: code, couponPercentOff: 0 }),
applyCoupon: () => {
const c = get().couponCode.trim().toUpperCase();
if (c === "SHITAYE10") set({ couponPercentOff: 10 });
else if (c === "WELCOME5") set({ couponPercentOff: 5 });
else set({ couponPercentOff: 0 });
},
setHoldReference: (holdReference) => set({ holdReference }),
setPayLaterHold: (payLaterHold) => set({ payLaterHold }),
setConfirmation: (confirmationId, paidAt) =>
set({
confirmationId,
paidAt,
...(confirmationId ? { payLaterHold: false } : {}),
}),
setLastCreatedBooking: (lastCreatedBooking) => set({ lastCreatedBooking }),
resetBooking: () => {
const nd = defaultDates();
set({
checkIn: nd.checkIn,
checkOut: nd.checkOut,
guests: 2,
roomId: null,
guest: { ...emptyGuest },
couponCode: "",
couponPercentOff: 0,
holdReference: null,
payLaterHold: false,
confirmationId: null,
paidAt: null,
lastCreatedBooking: null,
});
},
refreshRooms: async () => {
const pid = getHotelPropertyId();
if (!pid) {
set({
rooms: [],
roomsError:
"Set NEXT_PUBLIC_HOTEL_PROPERTY_ID to load bookable rooms from the API.",
roomsLoading: false,
});
return;
}
set({ roomsLoading: true, roomsError: null });
try {
const data = await fetchPublicRooms(pid);
set({ rooms: data.map(mapApiRoomToRoom), roomsLoading: false, roomsError: null });
} catch (e) {
set({
rooms: [],
roomsError: e instanceof Error ? e.message : "Could not load rooms from API.",
roomsLoading: false,
});
}
},
}));
function pickBookingActions(s: BookingState) {
return {
setDates: s.setDates,
setGuests: s.setGuests,
setRoomId: s.setRoomId,
setGuest: s.setGuest,
setCouponCode: s.setCouponCode,
applyCoupon: s.applyCoupon,
setHoldReference: s.setHoldReference,
setPayLaterHold: s.setPayLaterHold,
setConfirmation: s.setConfirmation,
setLastCreatedBooking: s.setLastCreatedBooking,
resetBooking: s.resetBooking,
refreshRooms: s.refreshRooms,
};
}
/** Drop-in replacement for the former `useBooking` context hook. */
export function useBooking(): BookingView {
const base = useBookingStore(
useShallow((s) => ({
checkIn: s.checkIn,
checkOut: s.checkOut,
guests: s.guests,
roomId: s.roomId,
guest: s.guest,
couponCode: s.couponCode,
couponPercentOff: s.couponPercentOff,
holdReference: s.holdReference,
payLaterHold: s.payLaterHold,
confirmationId: s.confirmationId,
paidAt: s.paidAt,
rooms: s.rooms,
roomsLoading: s.roomsLoading,
roomsError: s.roomsError,
lastCreatedBooking: s.lastCreatedBooking,
...pickBookingActions(s),
})),
);
return useMemo(() => {
const selectedRoom = base.rooms.find((r) => r.id === base.roomId) ?? null;
const nights = nightsBetween(base.checkIn, base.checkOut);
const subtotal = selectedRoom ? selectedRoom.nightlyRate * nights : 0;
const discountAmount = Math.round(subtotal * (base.couponPercentOff / 100) * 100) / 100;
const afterDiscount = Math.max(0, subtotal - discountAmount);
const taxAmount = Math.round(afterDiscount * siteConfig.taxRate * 100) / 100;
const total = Math.round((afterDiscount + taxAmount) * 100) / 100;
return {
...base,
selectedRoom,
nights,
subtotal,
discountAmount,
taxAmount,
total,
};
}, [base]);
}

View File

@ -0,0 +1,44 @@
import { useMemo } from "react";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
type CurrencyCode,
convertFromUsd,
formatMoneyFromUsd,
isCurrencyCode,
} from "@/lib/currency";
type CurrencyState = {
currency: CurrencyCode;
setCurrency: (c: CurrencyCode) => void;
};
export const useCurrencyStore = create<CurrencyState>()(
persist(
(set) => ({
currency: "USD",
setCurrency: (currency) => set({ currency: isCurrencyCode(currency) ? currency : "USD" }),
}),
{
name: "shitaye-currency",
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ currency: s.currency }),
skipHydration: true,
},
),
);
export function useCurrency() {
const currency = useCurrencyStore((s) => s.currency);
const setCurrency = useCurrencyStore((s) => s.setCurrency);
return useMemo(
() => ({
currency,
setCurrency,
formatUsd: (amountUsd: number, maximumFractionDigits: 0 | 1 | 2 = 2) =>
formatMoneyFromUsd(amountUsd, currency, maximumFractionDigits),
convertUsd: (amountUsd: number) => convertFromUsd(amountUsd, currency),
}),
[currency, setCurrency],
);
}

View File

@ -0,0 +1,14 @@
import { create } from "zustand";
/** Local-only loyalty bonus shown in the demo profile (not server points). */
type GuestUiState = {
localBonusPoints: number;
addLocalBonus: (n: number) => void;
resetLocalBonus: () => void;
};
export const useGuestUiStore = create<GuestUiState>()((set) => ({
localBonusPoints: 0,
addLocalBonus: (n) => set((s) => ({ localBonusPoints: s.localBonusPoints + n })),
resetLocalBonus: () => set({ localBonusPoints: 0 }),
}));

View File

@ -0,0 +1,18 @@
import { create } from "zustand";
import type { OrderRecord } from "@/types/guest-order";
type OrdersState = {
orders: OrderRecord[];
/** Append one order to the in-memory state. */
pushOrder: (rec: OrderRecord) => void;
setOrders: (next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => void;
};
export const useOrdersStore = create<OrdersState>((set) => ({
orders: [],
pushOrder: (rec) => set((s) => ({ orders: [rec, ...s.orders] })),
setOrders: (next) =>
set((s) => ({
orders: typeof next === "function" ? next(s.orders) : next,
})),
}));

11
src/types/guest-order.ts Normal file
View File

@ -0,0 +1,11 @@
export type OrderCategory = "room-service" | "laundry" | "gym" | "spa";
export type OrderRecord = {
id: string;
category: OrderCategory;
title: string;
detail: string;
totalUsd: number;
placedAt: string;
status: "pending" | "confirmed" | "completed";
};

26
src/types/next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session extends DefaultSession {
accessToken?: string;
role?: string;
propertyId?: string;
/** Set when signing in with booking code */
bookingCode?: string | null;
bookingId?: string | null;
authMethod?: "otp" | "password" | "google";
error?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
accessToken?: string;
role?: string;
propertyId?: string;
bookingCode?: string | null;
bookingId?: string | null;
authMethod?: "otp" | "password" | "google";
error?: string;
}
}

17
src/types/room.ts Normal file
View File

@ -0,0 +1,17 @@
export type Room = {
id: string;
slug: string;
imageKeys?: string[];
name: string;
shortDescription: string;
longDescription: string;
nightlyRate: number;
priceCurrency: "USD" | "ETB";
maxGuests: number;
beds: string;
sizeSqM: number;
view: string;
highlights: string[];
gallery: string[];
tourEmbedUrl: string | null;
};