Compare commits

..

No commits in common. "f3e7896169abd22391e785bd3ce841916600a871" and "d5c7d56c11aa03d7eaf698fffa54eda70ef90f54" have entirely different histories.

66 changed files with 1552 additions and 2496 deletions

View File

@ -4,7 +4,6 @@ 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: "/**" },
],
@ -12,3 +11,5 @@ const nextConfig: NextConfig = {
};
export default nextConfig;
import('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev());

328
package-lock.json generated
View File

@ -9,10 +9,8 @@
"version": "0.1.0",
"dependencies": {
"next": "16.2.1",
"next-auth": "^4.24.11",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "^5.0.8"
"react-dom": "19.2.4"
},
"devDependencies": {
"@opennextjs/aws": "^3.9.16",
@ -104,6 +102,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -121,6 +122,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -138,6 +142,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -155,6 +162,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1625,14 +1635,6 @@
"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",
@ -1929,7 +1931,6 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@ -2602,7 +2603,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2625,7 +2625,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2648,7 +2647,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2665,7 +2663,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2682,7 +2679,9 @@
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2699,7 +2698,9 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2716,7 +2717,9 @@
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2733,7 +2736,9 @@
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2750,7 +2755,9 @@
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2767,7 +2774,9 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2784,7 +2793,9 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2801,7 +2812,9 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2818,7 +2831,9 @@
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2841,7 +2856,9 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2864,7 +2881,9 @@
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2887,7 +2906,9 @@
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2910,7 +2931,9 @@
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2933,7 +2956,9 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2956,7 +2981,9 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2979,7 +3006,9 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -3002,7 +3031,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@ -3022,7 +3050,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@ -3042,7 +3069,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@ -3062,7 +3088,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@ -3214,6 +3239,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3230,6 +3258,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3246,6 +3277,9 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3262,6 +3296,9 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3595,14 +3632,6 @@
"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",
@ -4598,6 +4627,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4615,6 +4647,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4632,6 +4667,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4649,6 +4687,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4800,7 +4841,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -5217,6 +5258,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5231,6 +5275,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5245,6 +5292,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5259,6 +5309,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5273,6 +5326,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5287,6 +5343,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5301,6 +5360,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5315,6 +5377,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -6198,7 +6263,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -8652,14 +8717,6 @@
"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",
@ -8940,6 +8997,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8961,6 +9021,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -8982,6 +9045,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9003,6 +9069,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9403,45 +9472,6 @@
}
}
},
"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",
@ -9551,11 +9581,6 @@
"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",
@ -9566,14 +9591,6 @@
"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",
@ -9704,14 +9721,6 @@
"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",
@ -9751,36 +9760,6 @@
"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",
@ -10021,26 +10000,6 @@
"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",
@ -10051,11 +10010,6 @@
"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",
@ -11472,14 +11426,6 @@
"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",
@ -12366,34 +12312,6 @@
"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,10 +15,8 @@
},
"dependencies": {
"next": "16.2.1",
"next-auth": "^4.24.11",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "^5.0.8"
"react-dom": "19.2.4"
},
"devDependencies": {
"@opennextjs/aws": "^3.9.16",

View File

@ -1,6 +0,0 @@
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,8 +6,9 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { RoomSelectBooking } from "@/components/RoomSelectBooking";
import { useBooking } from "@/context/BookingContext";
import { siteConfig } from "@/lib/site-config";
import { createPublicBooking, ensurePropertyId } from "@/lib/public-hotel-api";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import { submitBookingHold } from "@/lib/mocks/api";
export function BookingPageClient() {
const searchParams = useSearchParams();
@ -23,9 +24,6 @@ export function BookingPageClient() {
setPayLaterHold,
selectedRoom,
nights,
rooms,
couponCode,
setLastCreatedBooking,
} = useBooking();
const [pending, setPending] = useState<null | "payment" | "reserve">(null);
@ -34,55 +32,33 @@ export function BookingPageClient() {
useEffect(() => {
const r = searchParams.get("room");
if (r && rooms.some((x) => x.id === r)) setRoomId(r);
}, [searchParams, setRoomId, rooms]);
}, [searchParams, setRoomId]);
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 propertyId = await ensurePropertyId();
const booking = await createPublicBooking(propertyId, {
const { reference } = await submitBookingHold({
roomId: selectedRoom.id,
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(),
email: guest.email,
flightBookingNumber: guest.flightBookingNumber.trim(),
arrivalTime: guest.arrivalTime.trim(),
discountCode: couponCode.trim() || undefined,
payLaterHold: mode === "reserve",
});
const code = booking.bookingCode ?? "";
setHoldReference(code);
setHoldReference(reference);
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 (e) {
setError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
} catch {
setError("Something went wrong. Please try again.");
} finally {
setPending(null);
}
@ -93,9 +69,11 @@ 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)]">
Live rates from the hotel. You&apos;ll receive a booking code to sign in and manage your stay.
Pay now, or reserve first and complete payment later in this session mock only.
</p>
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
@ -228,8 +206,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 hold; you&apos;ll get a booking code. Payment is completed at the hotel unless
you add card checkout later.
Pay later keeps your details and hold reference; finish checkout from the next screen
whenever you&apos;re ready.
</p>
</div>
</div>

View File

@ -6,9 +6,8 @@ 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/site-config";
import { siteConfig } from "@/lib/mocks/site";
export default function ConfirmationPage() {
const router = useRouter();
@ -22,8 +21,6 @@ export default function ConfirmationPage() {
nights,
total,
resetBooking,
holdReference,
lastCreatedBooking,
} = useBooking();
const { formatUsd } = useCurrency();
@ -44,16 +41,11 @@ 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}. Confirmation details have been sent to {guest.email}.
Thank you, {guest.firstName}. A mock itinerary email would be 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()}
@ -93,12 +85,7 @@ export default function ConfirmationPage() {
</span>
</p>
</div>
<p className="font-semibold">
Total paid:{" "}
{lastCreatedBooking?.currency === "ETB" || selectedRoom.priceCurrency === "ETB"
? formatEtb(lastCreatedBooking?.totalPrice ?? total)
: formatUsd(lastCreatedBooking?.totalPrice ?? total)}
</p>
<p className="font-semibold">Total paid: {formatUsd(total)}</p>
</div>
</div>

View File

@ -1,13 +1,10 @@
"use client";
import Link from "next/link";
import { useState, useEffect, useCallback } from "react";
import { useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
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";
import { laundryItems } from "@/lib/mocks/laundryCatalog";
export function LaundryClient() {
return (
@ -18,216 +15,155 @@ export function LaundryClient() {
}
function LaundryInner() {
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 { addOrder } = useAuth();
const [qty, setQty] = useState<Record<string, number>>({});
const [express, setExpress] = useState(false);
const [sent, setSent] = useState(false);
const [submitErr, setSubmitErr] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
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,
});
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);
}
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 needBooking = !bookingLoading && !bookingId;
const hasItems = buildItems().length > 0;
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 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;
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);
setSent(true);
}
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)]">Submit a laundry request attached to your active booking.</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)]">
Select pieces and optional express surcharge. Mock request pickup at reception.
</p>
</div>
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">View profile </Link>
<Link href="/profile" className="text-sm font-semibold text-[var(--color-accent)] hover:underline">
View profile
</Link>
</div>
{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}
{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.
Request logged (demo). Our team will confirm timing by phone.
</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={() => 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-16 text-center font-mono text-lg font-semibold">{qty}</span>
<button
type="button"
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>
);
})}
</div>
</div>
{/* 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
onClick={submit}
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"
<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"
>
{submitting ? "Submitting..." : `Place laundry order (${formatEtb(displayTotal)})`}
<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>
</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"
>
</button>
<span className="w-8 text-center font-semibold">{qty[row.id] ?? 0}</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"
>
+
</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>
<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>
<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"
>
Submit laundry request
</button>
</div>
</div>
)}
</aside>
</div>
</div>
</div>
);

View File

@ -1,15 +1,18 @@
"use client";
import type { Metadata } from "next";
import Link from "next/link";
import { siteConfig } from "@/lib/site-config";
import { useAuth } from "@/context/AuthContext";
import { siteConfig } from "@/lib/mocks/site";
export const metadata: Metadata = {
title: "Guest hub",
description: "Digital room service, laundry, gym, and spa — order during your stay at Shitaye.",
};
const tiles = [
{
href: "/guest/room-service",
title: "Digital menu",
subtitle: "Room service",
desc: "Breakfast through late evening — send orders directly to the kitchen.",
desc: "Breakfast through late evening — add to tray and send to the kitchen (demo).",
icon: "🍽",
},
{
@ -36,8 +39,6 @@ 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">
@ -53,27 +54,20 @@ 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.
{!session && (
<>
{" "}
Sign in with email or{" "}
<span className="font-medium text-[var(--color-text)]">booking reference</span> to track
orders on your profile.
</>
)}
Order to your room, schedule laundry, and book gym & spa all in one place. 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="/login" className="btn-mustard px-6 py-3 text-sm">
Sign in
</Link>
)}
<Link href="/login" className="btn-mustard px-6 py-3 text-sm">
Sign in
</Link>
<Link
href="/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)]"
>
{session ? "My profile & orders" : "My stay profile"}
My stay profile
</Link>
</div>
</div>

View File

@ -2,17 +2,15 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
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",
};
import {
roomServiceCategories,
roomServiceItems,
type MenuCategory,
type MenuItem,
} from "@/lib/mocks/roomServiceMenu";
export function RoomServiceClient() {
return (
@ -23,63 +21,15 @@ export function RoomServiceClient() {
}
function RoomServiceInner() {
const { accessToken } = useAuth();
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
const [cat, setCat] = useState<string>("");
const { addOrder } = useAuth();
const [cat, setCat] = useState<MenuCategory>("breakfast");
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);
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]);
const items = useMemo(
() => roomServiceItems.filter((i) => i.category === cat),
[cat],
);
function bump(id: string, delta: number) {
setQty((prev) => {
@ -92,42 +42,34 @@ function RoomServiceInner() {
}
const cartLines = useMemo(() => {
const lines: { id: string; name: string; unit: number; count: number }[] = [];
const lines: { item: MenuItem; count: number }[] = [];
for (const id of Object.keys(qty)) {
const row = apiMenu?.find((i) => i.id === id);
const item = roomServiceItems.find((i) => i.id === id);
const count = qty[id];
if (row && count > 0) {
lines.push({ id: row.id, name: row.name, unit: Number(row.unitPrice), count });
}
if (item && count > 0) lines.push({ item, count });
}
return lines;
}, [qty, apiMenu]);
}, [qty]);
const subtotal = useMemo(() => {
return cartLines.reduce((s, l) => s + l.unit * l.count, 0);
}, [cartLines]);
const subtotal = useMemo(
() => cartLines.reduce((s, l) => s + l.item.priceUsd * l.count, 0),
[cartLines],
);
async function submit() {
function submit() {
if (cartLines.length === 0) return;
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);
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",
});
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">
@ -149,44 +91,23 @@ function RoomServiceInner() {
Digital menu
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Orders go to the hotel kitchen when you are checked in with an active booking.
Mock ordering your tray appears on your profile under orders. Service charges may
apply.
</p>
</div>
<Link
href="/profile"
className="text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
<Link href="/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 submitted. You can add another round or check your profile for the local tray summary.
Order sent to the kitchen queue (demo). Add another round or check your profile.
</div>
) : null}
<div className="mt-8 flex flex-wrap gap-2">
{tabs.map((c) => (
{roomServiceCategories.map((c) => (
<button
key={c.id}
type="button"
@ -204,53 +125,47 @@ 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((row) => (
<article
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={row.image}
alt=""
fill
className="object-cover opacity-90"
sizes="(max-width:1024px) 50vw, 25vw"
/>
</div>
<h2 className="mt-3 font-heading text-lg font-semibold text-[var(--color-text)]">
{row.name}
</h2>
{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)]">
{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(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[row.id] ?? 0}</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"
aria-label="Increase"
>
+
</button>
</div>
</article>
{items.map((item) => (
<article
key={item.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"
alt=""
fill
className="object-cover opacity-90"
sizes="(max-width:1024px) 50vw, 25vw"
/>
</div>
<h2 className="mt-3 font-heading text-lg font-semibold text-[var(--color-text)]">
{item.name}
</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">{item.description}</p>
<p className="mt-2 text-sm font-semibold text-[var(--color-primary)]">
${item.priceUsd}
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => bump(item.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>
<button
type="button"
onClick={() => bump(item.id, 1)}
className="h-9 w-9 rounded-full border border-[var(--color-border)] text-lg font-semibold"
aria-label="Increase"
>
+
</button>
</div>
</article>
))}
</div>
@ -264,38 +179,26 @@ function RoomServiceInner() {
) : (
<ul className="mt-4 space-y-2 text-sm">
{cartLines.map((l) => (
<li key={l.id} className="flex justify-between gap-2">
<span className="text-[var(--color-text)]">
{l.name} ×{l.count}
</span>
<span className="font-medium">
{(l.unit * l.count).toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</span>
</li>
<li key={l.item.id} className="flex justify-between gap-2">
<span className="text-[var(--color-text)]">
{l.item.name} ×{l.count}
</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.toLocaleString(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
})}
</span>
<span className="font-heading text-xl font-semibold">${subtotal.toFixed(2)}</span>
</div>
<button
type="button"
onClick={() => void submit()}
disabled={cartLines.length === 0 || submitting || !!needBooking || bookingLoading}
onClick={submit}
disabled={cartLines.length === 0}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "Sending…" : "Send to kitchen"}
Send to kitchen
</button>
</div>
</aside>

View File

@ -12,7 +12,13 @@ export function LoginPageClient() {
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") || "/profile";
const { requestOtp, verifyOtp, loginPassword, loginGoogle, loginBookingRef } = useAuth();
const {
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
} = useAuth();
const [tab, setTab] = useState<Tab>("otp");
const [email, setEmail] = useState("");
@ -53,17 +59,15 @@ export function LoginPageClient() {
if (r.ok) router.push(nextPath);
}
async function handleGoogle() {
setMessage(null);
await loginGoogle();
function handleSocial(provider: "google" | "apple" | "facebook") {
loginSocial(provider);
router.push(nextPath);
}
async function handleBookingRef(e: React.FormEvent) {
function handleBookingRef(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
setLoading(true);
const r = await loginBookingRef(bookingRef);
setLoading(false);
const r = loginBookingRef(bookingRef);
setMessage(r.message);
if (r.ok) router.push(nextPath);
}
@ -158,7 +162,7 @@ export function LoginPageClient() {
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
Use the code sent to your email (hotel guest OTP).
Demo: enter <strong>123456</strong>
</p>
<div className="flex gap-2">
<button
@ -208,7 +212,7 @@ export function LoginPageClient() {
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
Use your Yaltopia homes account password
Demo password: <strong>shitaye</strong> or <strong>demo123</strong>
</p>
<button
type="submit"
@ -222,20 +226,32 @@ 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={() => void handleGoogle()}
onClick={() => handleSocial("google")}
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>
<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>
G
</span>
Login with Google
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
</button>
</div>
)}
@ -253,15 +269,11 @@ export function LoginPageClient() {
/>
</label>
<p className="text-xs text-[var(--color-muted)]">
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.
Try <strong>SHITAYE-2026-DEMO</strong> or <strong>GUEST-1234</strong> no email
required. You can place orders and view a limited stay profile.
</p>
<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 type="submit" className="btn-mustard w-full justify-center py-3 text-sm">
Continue with booking ID
</button>
</form>
)}

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/data/amenities";
import { roomAmenities } from "@/lib/mocks/amenities";
import {
getAllMeetingSlugs,
getMeetingSpaceBySlug,
} from "@/lib/data/meetingSpaces";
import { siteConfig } from "@/lib/site-config";
} from "@/lib/mocks/meetingSpaces";
import { siteConfig } from "@/lib/mocks/site";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };

View File

@ -3,14 +3,15 @@ import Link from "next/link";
import { AmenityItem } from "@/components/AmenityItem";
import { BookingSearchWidget } from "@/components/BookingSearchWidget";
import { OutletCard } from "@/components/OutletCard";
import { CatalogRoomsSection } from "@/components/CatalogRoomsSection";
import { RoomCard } from "@/components/RoomCard";
import { GoogleMapEmbed } from "@/components/GoogleMapEmbed";
import { VirtualTourBlock } from "@/components/VirtualTourBlock";
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";
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";
const heroImage =
"https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1920&q=80";
@ -163,7 +164,11 @@ export default function HomePage() {
Book a room
</Link>
</div>
<CatalogRoomsSection />
<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>
</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/site-config";
import { siteConfig } from "@/lib/mocks/site";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { formatEtb } from "@/lib/format-etb";
import { processPayment } from "@/lib/mocks/api";
export function PaymentPageClient() {
const router = useRouter();
@ -27,7 +27,6 @@ export function PaymentPageClient() {
holdReference,
payLaterHold,
setConfirmation,
lastCreatedBooking,
} = useBooking();
const { formatUsd } = useCurrency();
@ -39,31 +38,26 @@ export function PaymentPageClient() {
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!selectedRoom || !guest.email || !holdReference) {
if (!selectedRoom || !guest.email) {
router.replace("/booking");
}
}, [selectedRoom, guest.email, holdReference, router]);
}, [selectedRoom, guest.email, router]);
if (!selectedRoom || !holdReference) {
if (!selectedRoom) {
return null;
}
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)}`;
const payLabel = `Confirm & pay ${formatUsd(total)}`;
async function handlePay() {
setLoading(true);
try {
// 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());
const last4 = cardNumber.replace(/\D/g, "").slice(-4) || "0000";
const result = await processPayment({
totalCents: Math.round(total * 100),
last4,
});
setConfirmation(result.confirmationId, result.paidAt);
router.push("/confirmation");
} finally {
setLoading(false);
@ -74,8 +68,7 @@ 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)]">
Payment gateway is not connected yet confirming here records intent; settle at the front desk
or add a card processor later.
Mock form only read our privacy policy before a real launch.
</p>
{payLaterHold ? (
@ -94,7 +87,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 (optional placeholder)
Card details (demo)
</h2>
<label className="mt-4 block text-sm">
<span className="mb-1 block text-[var(--color-muted)]">Cardholder name</span>
@ -163,25 +156,23 @@ export function PaymentPageClient() {
<dl className="mt-6 space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-[var(--color-muted)]">
{payIsEtb
? `${formatEtb(selectedRoom.nightlyRate, 0)} × ${nights} nights`
: `${formatUsd(selectedRoom.nightlyRate)} × ${nights} nights`}
{formatUsd(selectedRoom.nightlyRate)} × {nights} nights
</dt>
<dd>{payIsEtb ? formatEtb(subtotal) : formatUsd(subtotal)}</dd>
<dd>{formatUsd(subtotal)}</dd>
</div>
{discountAmount > 0 ? (
<div className="flex justify-between text-[var(--color-success)]">
<dt>Discount</dt>
<dd>{payIsEtb ? `-${formatEtb(discountAmount)}` : `-${formatUsd(discountAmount)}`}</dd>
<dd>-{formatUsd(discountAmount)}</dd>
</div>
) : null}
<div className="flex justify-between">
<dt className="text-[var(--color-muted)]">Taxes & fees ({siteConfig.taxRate * 100}%)</dt>
<dd>{payIsEtb ? formatEtb(taxAmount) : formatUsd(taxAmount)}</dd>
<dd>{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>{payIsEtb ? formatEtb(total) : formatUsd(total)}</dd>
<dd>{formatUsd(total)}</dd>
</div>
</dl>
</div>

View File

@ -1,20 +1,16 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import type { OrderCategory, OrderRecord } from "@/context/AuthContext";
import {
guestMe,
guestOrders,
guestPointsHistory,
guestSpaBookings,
guestShuttles,
type PointLedgerRow,
type SpaBookingRow,
type ShuttleRow,
} from "@/lib/guest-hotel-api";
seedAppointments,
seedRewardsHistory,
seedShuttle,
} from "@/lib/mocks/guestData";
import { siteConfig } from "@/lib/mocks/site";
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
{ id: "all", label: "All" },
@ -64,80 +60,13 @@ export function ProfilePageClient() {
}
function ProfileContent() {
const { session, logout, accessToken } = useAuth();
const { session, orders, logout } = useAuth();
const [orderFilter, setOrderFilter] = useState<OrderCategory | "all">("all");
const [apiBalance, setApiBalance] = useState<number | null>(null);
const [apiLedger, setApiLedger] = useState<PointLedgerRow[]>([]);
const [apiOrders, setApiOrders] = useState<OrderRecord[]>([]);
const [appointments, setAppointments] = useState<SpaBookingRow[]>([]);
const [apiShuttles, setApiShuttles] = useState<ShuttleRow[]>([]);
useEffect(() => {
if (!accessToken || !session) return;
const pid = session.propertyId;
if (!pid) return;
let cancelled = false;
(async () => {
try {
const me = await guestMe(pid, accessToken);
const ph = await guestPointsHistory(pid, accessToken);
const ord = await guestOrders(pid, accessToken);
const spa = await guestSpaBookings(pid, accessToken);
let shuttles: ShuttleRow[] = [];
if (session.bookingId) {
try {
const sh = await guestShuttles(pid, session.bookingId, accessToken);
shuttles = (sh.data ?? []).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
} catch {
// Ignore if shuttles fail (e.g. no access or 404)
}
}
if (!cancelled) {
setApiBalance(me.balance);
setApiLedger(ph.data ?? []);
setApiOrders(
(ord.data ?? []).map((o) => ({
id: o.id,
category: o.type,
title:
o.type === "room-service"
? "Room Service Order"
: o.type === "laundry"
? "Laundry Request"
: o.type === "gym"
? "Gym Booking"
: "Spa Booking",
detail: o.detail,
totalUsd: Number(o.total ?? 0),
placedAt: o.createdAt,
status: (["pending", "confirmed", "completed"].includes(o.status.toLowerCase())
? o.status.toLowerCase()
: "pending") as OrderRecord["status"],
})),
);
setAppointments(spa.data ?? []);
setApiShuttles(shuttles);
}
} catch {
if (!cancelled) {
setApiOrders([]);
setAppointments([]);
setApiShuttles([]);
}
}
})();
return () => {
cancelled = true;
};
}, [accessToken, session]);
const filteredOrders = useMemo(() => {
if (orderFilter === "all") return apiOrders;
return apiOrders.filter((o) => o.category === orderFilter);
}, [apiOrders, orderFilter]);
if (orderFilter === "all") return orders;
return orders.filter((o) => o.category === orderFilter);
}, [orders, orderFilter]);
if (!session) {
return null;
@ -157,19 +86,24 @@ 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">
Hello, {session.displayName}
{session.kind === "member"
? `Hello, ${session.displayName}`
: `Welcome, ${session.guestName}`}
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{session.bookingCode ? (
{session.kind === "member" ? (
<>
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{" · "}
Booking code{" "}
<span className="font-mono font-semibold text-[var(--color-text)]">
{session.bookingCode}
</span>
Signed in via {session.authMethod}
</>
) : null}
) : (
<>
Booking <span className="font-mono font-semibold">{session.bookingRef}</span>
{" · "}
{session.roomLabel} · checkout {session.checkOut}
</>
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
@ -191,82 +125,75 @@ function ProfileContent() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Rewards points
</p>
<>
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{(apiBalance ?? session.points).toLocaleString()}
{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
</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>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{apiBalance != null ? "Balance" : "Balance unavailable"}
</p>
</>
)}
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm lg:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Airport shuttle
</p>
<p className="mt-2 font-heading text-xl text-[var(--color-text)]">
Lobby pickup · {seedShuttle.lobbyPickupTime}
</p>
<p className="mt-2 text-sm text-[var(--color-muted)]">
{new Date(seedShuttle.departureDate).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
})}{" "}
· {seedShuttle.airport}
</p>
<p className="mt-1 text-sm font-medium text-[var(--color-text)]">
{seedShuttle.flightLabel}
</p>
<p className="mt-3 text-xs text-[var(--color-muted)]">{seedShuttle.notes}</p>
<a
href={`mailto:${siteConfig.email}?subject=Shuttle%20change`}
className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
Request a change
</a>
</div>
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
{appointments.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-sm text-[var(--color-muted)]">
No gym/spa bookings found.
</p>
) : (
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{appointments.map((a) => (
<li
key={a.id}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
{a.status}
</p>
<p className="mt-2 font-semibold text-[var(--color-text)]">
{a.offering?.name ?? "Spa/Gym booking"}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{formatWhen(a.scheduledAt ?? a.createdAt)}
</p>
</li>
))}
</ul>
)}
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{seedAppointments.map((a) => (
<li
key={a.id}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
{a.status}
</p>
<p className="mt-2 font-semibold text-[var(--color-text)]">{a.title}</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.when}</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">{a.where}</p>
</li>
))}
</ul>
</section>
{session.bookingId && (
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Airport shuttle</h2>
{apiShuttles.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-sm text-[var(--color-muted)]">
No airport shuttles requested.
</p>
) : (
<ul className="mt-4 grid gap-3 md:grid-cols-2">
{apiShuttles.map((s) => (
<li
key={s.id}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
{s.status}
</p>
<p className="mt-2 font-semibold text-[var(--color-text)]">
{s.direction === "AIRPORT_TO_HOTEL" ? "Airport pickup (to hotel)" : s.direction === "HOTEL_TO_AIRPORT" ? "Hotel drop-off (to airport)" : s.direction.replace(/_/g, " ")}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Requested for: {formatWhen(s.requestedAt)}
</p>
{s.flightRef && (
<p className="mt-1 text-sm text-[var(--color-muted)]">Flight: {s.flightRef}</p>
)}
</li>
))}
</ul>
)}
</section>
)}
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Orders</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Room service, laundry, gym, and spa
Room service, laundry, gym, and spa including demo history and new orders from this
device.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{orderTabs.map((t) => (
@ -298,33 +225,21 @@ function ProfileContent() {
</section>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards history</h2>
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards earned</h2>
<ul className="mt-4 space-y-2">
{apiLedger.length > 0
? apiLedger.map((r) => (
<li
key={r.id}
className="flex items-center justify-between rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
>
<div>
<p className="font-medium text-[var(--color-text)]">{r.reason.replace(/_/g, " ")}</p>
<p className="text-xs text-[var(--color-muted)]">{formatWhen(r.createdAt)}</p>
</div>
<span
className={
r.delta >= 0 ? "badge-mustard" : "rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800"
}
>
{r.delta >= 0 ? "+" : ""}
{r.delta} pts
</span>
</li>
))
: null}
{seedRewardsHistory.map((r) => (
<li
key={r.id}
className="flex items-center justify-between rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
>
<div>
<p className="font-medium text-[var(--color-text)]">{r.label}</p>
<p className="text-xs text-[var(--color-muted)]">{r.earnedAt}</p>
</div>
<span className="badge-mustard">+{r.points} pts</span>
</li>
))}
</ul>
{apiLedger.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-muted)]">No rewards history returned yet.</p>
) : null}
</section>
</div>
</div>

View File

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

View File

@ -6,9 +6,8 @@ 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/site-config";
import { siteConfig } from "@/lib/mocks/site";
export default function ReserveHeldPage() {
const router = useRouter();
@ -61,16 +60,14 @@ 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.
browser session. (Demo: no real hold or email.)
</p>
<p className="mt-2 text-center font-mono text-sm text-[var(--color-text)]">
Booking code: {holdReference}
Hold ref: {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)]">
{selectedRoom.priceCurrency === "ETB" ? formatEtb(total) : formatUsd(total)}
</span>
<span className="font-semibold text-[var(--color-text)]">{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/data/amenities";
import { getAllMarketingRoomSlugs, getMarketingRoomBySlug } from "@/lib/data/marketing-room-pages";
import { siteConfig } from "@/lib/site-config";
import { roomAmenities } from "@/lib/mocks/amenities";
import { getAllRoomSlugs, getRoomBySlug } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return getAllMarketingRoomSlugs().map((slug) => ({ slug }));
return getAllRoomSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const room = getMarketingRoomBySlug(slug);
const room = getRoomBySlug(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 = getMarketingRoomBySlug(slug);
const room = getRoomBySlug(slug);
if (!room) notFound();
return (

View File

@ -1,25 +1,167 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
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";
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>
);
}
export function ServicesPageClient() {
const searchParams = useSearchParams();
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);
const { session, addOrder } = useAuth();
const [filter, setFilter] = useState<SpaGymFilterId>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
const k = searchParams.get("kind");
@ -28,47 +170,49 @@ 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 offerings;
return offerings.filter((o) => (filter === "spa" ? o.kind !== "GYM_PASS" : o.kind === "GYM_PASS"));
}, [filter, offerings]);
if (filter === "all") return spaGymServices;
return spaGymServices.filter((s) => s.kind === filter);
}, [filter]);
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);
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;
});
}
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 (
@ -86,12 +230,18 @@ 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">
Book treatments and gym passes directly from live hotel offerings.
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).
</p>
</div>
</section>
<section className="mx-auto max-w-7xl px-4 py-10 md:px-8 md:py-14">
<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" : ""}`}
>
<div className="flex flex-wrap justify-center gap-2 md:justify-start md:gap-2.5">
{spaGymFilters.map((f) => {
const active = filter === f.id;
@ -112,95 +262,29 @@ 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) => (
<article
key={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>
<div className="grid gap-6 sm:grid-cols-2">
{filtered.map((service) => (
<ServiceCard
key={service.id}
service={service}
selected={selected.has(service.id)}
onToggle={() => toggle(service.id)}
/>
))}
</div>
<aside className="lg:sticky lg:top-28">
<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"
>
<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>
<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)]"
>
Save selection to my stay
</button>
) : null}
<Link
href="/guest"
className="mt-3 block text-center text-sm font-medium text-[var(--color-muted)] hover:text-[var(--color-accent)]"
@ -215,6 +299,24 @@ 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 and book live spa treatments and gym sessions at Shitaye Suite Hotel.",
"Browse spa treatments and gym sessions at Shitaye Suite Hotel — build a mock selection and send a request.",
};
export default function ServicesPage() {

View File

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

View File

@ -2,24 +2,17 @@
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");
}}
@ -28,7 +21,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)]"
}
>
{hasActiveStay ? "View my stay" : "Book this room"}
Book this room
</button>
);
}

View File

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

View File

@ -1,31 +0,0 @@
"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/site-config";
import { siteConfig } from "@/lib/mocks/site";
export function Footer() {
return (

View File

@ -1,7 +1,8 @@
import { siteConfig } from "@/lib/site-config";
import { siteConfig } from "@/lib/mocks/site";
/**
* 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,9 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { HeaderAccount } from "@/components/HeaderAccount";
import { CurrencySwitcher } from "@/components/CurrencySwitcher";
import { ReviewsMenu } from "@/components/ReviewsMenu";
import { siteConfig } from "@/lib/site-config";
import { useAuth } from "@/context/AuthContext";
import { siteConfig } from "@/lib/mocks/site";
const nav = [
{ href: "/#rooms", label: "Rooms" },
@ -19,8 +16,6 @@ const nav = [
];
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">
@ -77,14 +72,12 @@ export function Header() {
</nav>
<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>
)}
<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,8 +13,12 @@ export function HeaderAccount() {
}
if (session) {
const points = session.points;
const label = session.displayName.split(" ")[0] ?? "Guest";
const points =
session.kind === "member" ? session.points : "—";
const label =
session.kind === "member"
? session.displayName.split(" ")[0] ?? "Guest"
: session.guestName.split(" ")[0] ?? "Guest";
return (
<div className="flex items-center gap-2 sm:gap-3">
@ -23,7 +27,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} pts`}
{points !== "—" ? `${points} pts` : "Stay"}
</Link>
<Link
href="/profile"

View File

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

View File

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

View File

@ -1,23 +1,14 @@
import Image from "next/image";
import Link from "next/link";
import { RoomPrice } from "@/components/RoomPrice";
import type { Room } from "@/types/room";
import { FormattedUsd } from "@/components/FormattedUsd";
import type { Room } from "@/lib/mocks/rooms";
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={href} className="relative aspect-[4/3] overflow-hidden">
<Link href={`/rooms/${room.slug}`} className="relative aspect-[4/3] overflow-hidden">
<Image
src={room.gallery[0]!}
alt={room.name}
@ -26,13 +17,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 <RoomPrice room={room} maximumFractionDigits={0} />
From <FormattedUsd amountUsd={room.nightlyRate} 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={href} className="hover:text-[var(--color-primary)]">
<Link href={`/rooms/${room.slug}`} className="hover:text-[var(--color-primary)]">
{room.name}
</Link>
</h3>
@ -41,10 +32,10 @@ export function RoomCard({ room }: Props) {
</p>
<div className="mt-4 flex items-center justify-between gap-3">
<Link
href={href}
href={`/rooms/${room.slug}`}
className="text-sm font-semibold text-[var(--color-primary)] underline-offset-4 hover:underline"
>
{/^[0-9a-f-]{36}$/i.test(room.id) ? "Book this room" : "View details"}
View details
</Link>
<Link
href={`/booking?room=${room.id}`}

View File

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

View File

@ -1,15 +0,0 @@
"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/site-config";
import { siteConfig } from "@/lib/mocks/site";
import { Mock3DPlaceholder } from "./Mock3DPlaceholder";
import { VirtualTourEmbed } from "./VirtualTourEmbed";

View File

@ -4,111 +4,166 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
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";
import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData";
export type { OrderCategory, OrderRecord } from "@/types/guest-order";
const STORAGE_SESSION = "shitaye_session_v1";
const STORAGE_ORDERS = "shitaye_orders_v1";
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";
};
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;
tier: "Gold" | "Silver";
/** How they signed in — for display only */
authMethod: "otp" | "password" | "google" | "apple" | "facebook";
};
export type GuestSession = MemberSession;
export type BookingRefSession = {
kind: "bookingRef";
bookingRef: string;
guestName: string;
roomLabel: string;
checkOut: string;
};
export type GuestSession = MemberSession | BookingRefSession;
type AuthContextValue = {
session: GuestSession | null;
orders: OrderRecord[];
isHydrated: boolean;
accessToken: string | null;
/** Demo OTP is always 123456 */
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>;
loginSocial: (provider: "google" | "apple" | "facebook") => void;
loginBookingRef: (ref: string) => { ok: boolean; message: string };
logout: () => 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);
function loadOrders(): OrderRecord[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_ORDERS);
if (!raw) return seedOrders();
const parsed = JSON.parse(raw) as OrderRecord[];
return Array.isArray(parsed) ? parsed : seedOrders();
} catch {
return seedOrders();
}
}
function seedOrders(): OrderRecord[] {
return [
{
id: "seed-rs-1",
category: "room-service",
title: "Room service · American breakfast ×2",
detail: "Delivered 07:15 · Room charge",
totalUsd: 36,
placedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
status: "completed",
},
{
id: "seed-l-1",
category: "laundry",
title: "Laundry · Express + 3 shirts",
detail: "Returned same evening",
totalUsd: 27,
placedAt: new Date(Date.now() - 86400000).toISOString(),
status: "completed",
},
{
id: "seed-sp-1",
category: "spa",
title: "Spa · Signature Swedish 60 min",
detail: "Apr 4 · 15:00",
totalUsd: 85,
placedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
status: "confirmed",
},
];
}
function loadSession(): GuestSession | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(STORAGE_SESSION);
if (!raw) return null;
return JSON.parse(raw) as GuestSession;
} catch {
return null;
}
}
function persistSession(s: GuestSession | null) {
if (typeof window === "undefined") return;
if (s) localStorage.setItem(STORAGE_SESSION, JSON.stringify(s));
else localStorage.removeItem(STORAGE_SESSION);
}
function persistOrders(orders: OrderRecord[]) {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_ORDERS, JSON.stringify(orders));
}
export function AuthProvider({ children }: { children: ReactNode }) {
const { data, status } = useSession();
const orders = useOrdersStore((s) => s.orders);
const localBonusPoints = useGuestUiStore((s) => s.localBonusPoints);
const [session, setSession] = useState<GuestSession | null>(null);
const [orders, setOrders] = useState<OrderRecord[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
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]);
useEffect(() => {
setSession(loadSession());
setOrders(loadOrders());
setIsHydrated(true);
}, []);
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." };
}
return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." };
}, []);
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." };
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);
persistSession(next);
return { ok: true, message: "Signed in." };
}, []);
@ -116,47 +171,69 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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." };
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);
persistSession(next);
return { ok: true, message: "Signed in." };
}, []);
const loginGoogle = useCallback(async () => {
await signIn("google", { callbackUrl: "/", redirect: true });
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);
persistSession(next);
}, []);
const loginBookingRef = useCallback(async (ref: string) => {
const propertyId = getHotelPropertyId();
if (!propertyId) {
const loginBookingRef = useCallback((ref: string) => {
const key = ref.trim().toUpperCase();
const row = DEMO_BOOKING_REFS[key];
if (!row) {
return {
ok: false,
message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID).",
message: "Reference not found. Try SHITAYE-2026-DEMO or GUEST-1234.",
};
}
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 next: BookingRefSession = {
kind: "bookingRef",
bookingRef: key,
guestName: row.guestName,
roomLabel: row.room,
checkOut: row.checkOut,
};
setSession(next);
persistSession(next);
return { ok: true, message: "Linked to your stay." };
}, []);
const logout = useCallback(async () => {
useGuestUiStore.getState().resetLocalBonus();
await signOut({ callbackUrl: "/" });
}, []);
const setOrders = useCallback((next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => {
useOrdersStore.getState().setOrders(next);
const logout = useCallback(() => {
setSession(null);
persistSession(null);
}, []);
const addOrder = useCallback(
@ -171,49 +248,59 @@ export function AuthProvider({ children }: { children: ReactNode }) {
placedAt: new Date().toISOString(),
status: o.status ?? "pending",
};
useOrdersStore.getState().pushOrder(rec);
if (guestSession?.kind === "member") {
setOrders((prev) => {
const next = [rec, ...prev];
persistOrders(next);
return next;
});
if (session?.kind === "member") {
const bonus = Math.min(150, Math.round(o.totalUsd * 2));
useGuestUiStore.getState().addLocalBonus(bonus);
setSession((s) => {
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + bonus };
persistSession(u);
return u;
});
}
},
[guestSession],
[session],
);
const awardPoints = useCallback((points: number) => {
useGuestUiStore.getState().addLocalBonus(points);
setSession((s) => {
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + points };
persistSession(u);
return u;
});
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session: guestSession,
session,
orders,
isHydrated,
accessToken: data?.accessToken ?? null,
requestOtp,
verifyOtp,
loginPassword,
loginGoogle,
loginSocial,
loginBookingRef,
logout,
addOrder,
awardPoints,
setOrders,
}),
[
guestSession,
session,
orders,
isHydrated,
data?.accessToken,
requestOtp,
verifyOtp,
loginPassword,
loginGoogle,
loginSocial,
loginBookingRef,
logout,
addOrder,
awardPoints,
setOrders,
],
);

View File

@ -1,2 +1,218 @@
export type { GuestDetails, LastCreatedBooking, BookingView } from "@/stores/booking-store";
export { useBooking, useBookingStore } from "@/stores/booking-store";
"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;
}

View File

@ -1 +1,87 @@
export { useCurrency, useCurrencyStore } from "@/stores/currency-store";
"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;
}

View File

@ -1,45 +0,0 @@
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;
}

View File

@ -1,213 +0,0 @@
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,15 +1,14 @@
export type CurrencyCode = "USD" | "EUR" | "GBP" | "AED" | "ETB";
export type CurrencyCode = "USD" | "EUR" | "GBP" | "AED";
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,
@ -17,7 +16,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" || v === "ETB";
return v === "USD" || v === "EUR" || v === "GBP" || v === "AED";
}
export function convertFromUsd(usd: number, code: CurrencyCode): number {

View File

@ -1,34 +0,0 @@
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,10 +0,0 @@
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;
}

View File

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

View File

@ -1,309 +0,0 @@
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,
});
}

41
src/lib/mocks/api.ts Normal file
View File

@ -0,0 +1,41 @@
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,3 +1,20 @@
/** 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,45 @@
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",
},
];

103
src/lib/mocks/rooms.ts Normal file
View File

@ -0,0 +1,103 @@
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;
};
export const rooms: Room[] = [
{
id: "penthouse",
slug: "four-bedroom-penthouse",
name: "The 4 Bedroom Penthouse",
shortDescription: "Our flagship residence with panoramic views and full kitchenette.",
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,
maxGuests: 8,
beds: "4 bedrooms — mix of king and twin configurations",
sizeSqM: 220,
view: "City skyline",
highlights: ["Private routers", "IPTV", "Mini bar", "In-room safe"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&q=80&auto=format&fit=crop",
],
tourEmbedUrl: null,
},
{
id: "standard",
slug: "standard-rooms",
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.",
nightlyRate: 120,
maxGuests: 2,
beds: "1 King or 2 Twin",
sizeSqM: 28,
view: "City or courtyard",
highlights: ["B/B fast", "Iron & board", "Laundry (paid)", "Safe box"],
gallery: [
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&q=80",
"https://images.unsplash.com/photo-1566665797739-1674de7a215a?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "connecting-suite",
slug: "connecting-suite",
name: "Connecting Suite",
shortDescription: "Flexible suites — convert to a spacious family layout.",
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,
maxGuests: 5,
beds: "1 King + connecting twin room",
sizeSqM: 55,
view: "City",
highlights: ["Family-friendly layout", "Kitchenette", "IPTV", "Shuttle"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80",
"https://images.unsplash.com/photo-1591088398332-8a7791972843?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "junior-studio",
slug: "junior-studios",
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.",
nightlyRate: 95,
maxGuests: 2,
beds: "1 Queen",
sizeSqM: 32,
view: "Urban",
highlights: ["Kitchenette", "Mini bar", "Private router option"],
gallery: [
"https://images.unsplash.com/photo-1522771739844-6a9f6d5f14af?w=1200&q=80",
"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=1200&q=80",
],
tourEmbedUrl: null,
},
];
export function getRoomBySlug(slug: string): Room | undefined {
return rooms.find((r) => r.slug === slug);
}
export function getAllRoomSlugs(): string[] {
return rooms.map((r) => r.slug);
}

View File

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

View File

@ -1,65 +0,0 @@
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;
}

View File

@ -1,46 +0,0 @@
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,50 +0,0 @@
"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 };
}

View File

@ -1,262 +0,0 @@
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

@ -1,44 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,18 +0,0 @@
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,
})),
}));

View File

@ -1,11 +0,0 @@
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";
};

View File

@ -1,26 +0,0 @@
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;
}
}

View File

@ -1,17 +0,0 @@
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;
};