Compare commits

...

8 Commits

Author SHA1 Message Date
f3e7896169 shuttle and image render updates 2026-04-15 14:52:44 +03:00
c1f3461952 amenities and room service apis 2026-04-15 10:45:13 +03:00
618d30aeef public UI updates 2026-04-14 15:44:34 +03:00
0160816b8e booking and amenities 2026-04-14 15:43:20 +03:00
bcc3a8de15 site configs 2026-04-14 15:41:11 +03:00
c4748aa0ee layouts 2026-04-14 11:52:24 +03:00
429cdb7094 auth done 2026-04-14 11:50:43 +03:00
aba29922c7 removed dummy data 2026-04-13 17:06:04 +03:00
66 changed files with 2487 additions and 1543 deletions

View File

@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
{ protocol: "http", hostname: "82.112.253.199", pathname: "/**" },
{ protocol: "https", hostname: "images.pexels.com", pathname: "/**" },
{ protocol: "https", hostname: "cf.bstatic.com", pathname: "/**" },
],
@ -11,5 +12,3 @@ const nextConfig: NextConfig = {
};
export default nextConfig;
import('@opennextjs/cloudflare').then(m => m.initOpenNextCloudflareForDev());

328
package-lock.json generated
View File

@ -9,8 +9,10 @@
"version": "0.1.0",
"dependencies": {
"next": "16.2.1",
"next-auth": "^4.24.11",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"zustand": "^5.0.8"
},
"devDependencies": {
"@opennextjs/aws": "^3.9.16",
@ -102,9 +104,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -122,9 +121,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -142,9 +138,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -162,9 +155,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1635,6 +1625,14 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -1931,6 +1929,7 @@
"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": {
@ -2603,6 +2602,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2625,6 +2625,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2647,6 +2648,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2663,6 +2665,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2679,9 +2682,7 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2698,9 +2699,7 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2717,9 +2716,7 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2736,9 +2733,7 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2755,9 +2750,7 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2774,9 +2767,7 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2793,9 +2784,7 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2812,9 +2801,7 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -2831,9 +2818,7 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2856,9 +2841,7 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2881,9 +2864,7 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2906,9 +2887,7 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2931,9 +2910,7 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2956,9 +2933,7 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -2981,9 +2956,7 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -3006,9 +2979,7 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -3031,6 +3002,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@ -3050,6 +3022,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@ -3069,6 +3042,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@ -3088,6 +3062,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@ -3239,9 +3214,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3258,9 +3230,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3277,9 +3246,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3296,9 +3262,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3632,6 +3595,14 @@
"wrangler": "^4.65.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@ -4627,9 +4598,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4647,9 +4615,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4667,9 +4632,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4687,9 +4649,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4841,7 +4800,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -5258,9 +5217,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5275,9 +5231,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5292,9 +5245,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5309,9 +5259,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5326,9 +5273,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5343,9 +5287,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5360,9 +5301,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5377,9 +5315,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -6263,7 +6198,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -8717,6 +8652,14 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8997,9 +8940,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9021,9 +8961,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9045,9 +8982,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9069,9 +9003,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9472,6 +9403,45 @@
}
}
},
"node_modules/next-auth": {
"version": "4.24.13",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz",
"integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.3",
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
"nodemailer": "^7.0.7",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-auth/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -9581,6 +9551,11 @@
"node": ">=8"
}
},
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -9591,6 +9566,14 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -9721,6 +9704,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -9760,6 +9751,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -10000,6 +10021,26 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.29.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -10010,6 +10051,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -11426,6 +11472,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -12312,6 +12366,34 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -15,8 +15,10 @@
},
"dependencies": {
"next": "16.2.1",
"next-auth": "^4.24.11",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"zustand": "^5.0.8"
},
"devDependencies": {
"@opennextjs/aws": "^3.9.16",

View File

@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@ -6,9 +6,8 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { RoomSelectBooking } from "@/components/RoomSelectBooking";
import { useBooking } from "@/context/BookingContext";
import { rooms } from "@/lib/mocks/rooms";
import { siteConfig } from "@/lib/mocks/site";
import { submitBookingHold } from "@/lib/mocks/api";
import { siteConfig } from "@/lib/site-config";
import { createPublicBooking, ensurePropertyId } from "@/lib/public-hotel-api";
export function BookingPageClient() {
const searchParams = useSearchParams();
@ -24,6 +23,9 @@ export function BookingPageClient() {
setPayLaterHold,
selectedRoom,
nights,
rooms,
couponCode,
setLastCreatedBooking,
} = useBooking();
const [pending, setPending] = useState<null | "payment" | "reserve">(null);
@ -32,33 +34,55 @@ export function BookingPageClient() {
useEffect(() => {
const r = searchParams.get("room");
if (r && rooms.some((x) => x.id === r)) setRoomId(r);
}, [searchParams, setRoomId]);
}, [searchParams, setRoomId, rooms]);
const canContinue =
selectedRoom &&
guest.firstName.trim() &&
guest.lastName.trim() &&
guest.email.trim() &&
guest.phone.trim() &&
guest.flightBookingNumber.trim() &&
guest.arrivalTime.trim();
guest.phone.trim()
// guest.flightBookingNumber.trim() &&
// guest.arrivalTime.trim();
async function placeHold(mode: "payment" | "reserve") {
if (!canContinue || !selectedRoom) return;
setError(null);
setPending(mode);
try {
const { reference } = await submitBookingHold({
const propertyId = await ensurePropertyId();
const booking = await createPublicBooking(propertyId, {
roomId: selectedRoom.id,
email: guest.email,
flightBookingNumber: guest.flightBookingNumber.trim(),
checkIn,
checkOut,
guestCount: guests,
firstName: guest.firstName.trim(),
lastName: guest.lastName.trim(),
email: guest.email.trim().toLowerCase(),
phone: guest.phone.trim(),
flightPnr: guest.flightBookingNumber.trim(),
arrivalTime: guest.arrivalTime.trim(),
discountCode: couponCode.trim() || undefined,
payLaterHold: mode === "reserve",
});
setHoldReference(reference);
const code = booking.bookingCode ?? "";
setHoldReference(code);
setPayLaterHold(mode === "reserve");
const tp =
booking.totalPrice != null
? typeof booking.totalPrice === "string"
? Number.parseFloat(booking.totalPrice)
: booking.totalPrice
: 0;
setLastCreatedBooking({
id: booking.id,
bookingCode: booking.bookingCode,
totalPrice: Number.isFinite(tp) ? tp : 0,
currency: booking.currency ?? "ETB",
});
router.push(mode === "payment" ? "/payment" : "/reserve-held");
} catch {
setError("Something went wrong. Please try again.");
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong. Please try again.");
} finally {
setPending(null);
}
@ -69,11 +93,9 @@ export function BookingPageClient() {
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--color-primary)]">
Book your stay
</p>
<h1 className="mt-2 font-heading text-3xl md:text-4xl">
It only takes a moment
</h1>
<h1 className="mt-2 font-heading text-3xl md:text-4xl">It only takes a moment</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
Pay now, or reserve first and complete payment later in this session mock only.
Live rates from the hotel. You&apos;ll receive a booking code to sign in and manage your stay.
</p>
<div className="mt-8 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm">
@ -206,8 +228,8 @@ export function BookingPageClient() {
{pending === "reserve" ? "Saving your hold…" : "Reserve now — pay later"}
</button>
<p className="text-center text-xs text-[var(--color-muted)]">
Pay later keeps your details and hold reference; finish checkout from the next screen
whenever you&apos;re ready.
Pay later keeps your hold; you&apos;ll get a booking code. Payment is completed at the hotel unless
you add card checkout later.
</p>
</div>
</div>

View File

@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext";
import { formatEtb } from "@/lib/format-etb";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
export default function ConfirmationPage() {
const router = useRouter();
@ -21,6 +22,8 @@ export default function ConfirmationPage() {
nights,
total,
resetBooking,
holdReference,
lastCreatedBooking,
} = useBooking();
const { formatUsd } = useCurrency();
@ -41,11 +44,16 @@ export default function ConfirmationPage() {
</div>
<h1 className="mt-8 font-heading text-3xl md:text-4xl">Your booking is confirmed</h1>
<p className="mt-3 text-sm text-[var(--color-muted)]">
Thank you, {guest.firstName}. A mock itinerary email would be sent to {guest.email}.
Thank you, {guest.firstName}. Confirmation details have been sent to {guest.email}.
</p>
<p className="mt-2 font-mono text-sm text-[var(--color-text)]">
Confirmation: {confirmationId}
</p>
{holdReference ? (
<p className="mt-1 text-xs text-[var(--color-muted)]">
Booking code: <span className="font-mono text-[var(--color-text)]">{holdReference}</span>
</p>
) : null}
{paidAt ? (
<p className="mt-1 text-xs text-[var(--color-muted)]">
Paid at: {new Date(paidAt).toLocaleString()}
@ -85,7 +93,12 @@ export default function ConfirmationPage() {
</span>
</p>
</div>
<p className="font-semibold">Total paid: {formatUsd(total)}</p>
<p className="font-semibold">
Total paid:{" "}
{lastCreatedBooking?.currency === "ETB" || selectedRoom.priceCurrency === "ETB"
? formatEtb(lastCreatedBooking?.totalPrice ?? total)
: formatUsd(lastCreatedBooking?.totalPrice ?? total)}
</p>
</div>
</div>

View File

@ -1,10 +1,13 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import { laundryItems } from "@/lib/mocks/laundryCatalog";
import { guestPlaceLaundry } from "@/lib/guest-hotel-api";
import { formatEtb } from "@/lib/format-etb";
import { useGuestActiveBooking } from "@/lib/useGuestActiveBooking";
import { laundryItems, type LaundryCartItem, SAME_DAY_SURCHARGE } from "@/lib/data/laundryCatalog";
export function LaundryClient() {
return (
@ -15,155 +18,216 @@ export function LaundryClient() {
}
function LaundryInner() {
const { addOrder } = useAuth();
const [qty, setQty] = useState<Record<string, number>>({});
const [express, setExpress] = useState(false);
const { accessToken } = useAuth();
const { bookingId, loading: bookingLoading, propertyId } = useGuestActiveBooking();
// Form states
const [cart, setCart] = useState<Record<string, number>>({});
const [sameDay, setSameDay] = useState(false);
const [pickupAt, setPickupAt] = useState("");
const [deliverAt, setDeliverAt] = useState("");
const [notes, setNotes] = useState("");
const [sent, setSent] = useState(false);
const [submitErr, setSubmitErr] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
function bump(id: string, delta: number) {
setQty((prev) => {
const next = { ...prev };
const n = Math.max(0, (next[id] ?? 0) + delta);
if (n === 0) delete next[id];
else next[id] = n;
return next;
});
}
const canUseApi = !!(propertyId && accessToken && bookingId);
const lines = useMemo(() => {
const out: { id: string; name: string; count: number; unitUsd: number }[] = [];
for (const row of laundryItems) {
const q = qty[row.id];
if (q && q > 0) {
out.push({ id: row.id, name: row.name, count: q, unitUsd: row.priceUsd });
// Compute total
const total = useCallback(() => {
let sum = 0;
for (const [label, qty] of Object.entries(cart)) {
if (qty > 0) {
const price = laundryItems.find(item => item.label.toLowerCase() === label.toLowerCase())?.price || 0;
sum += price * qty;
}
}
return out;
}, [qty]);
if (sameDay) sum += SAME_DAY_SURCHARGE;
return sum;
}, [cart, sameDay]);
const subtotal = useMemo(() => {
let s = lines.reduce((a, l) => a + l.unitUsd * l.count, 0);
if (express) s += 15;
return s;
}, [lines, express]);
const [displayTotal, setDisplayTotal] = useState(0);
useEffect(() => {
setDisplayTotal(total());
}, [total]);
function submit() {
if (lines.length === 0 && !express) return;
const detail = [
...lines.map((l) => `${l.name} ×${l.count}`),
express ? "Express same-day (+$15)" : null,
]
.filter(Boolean)
.join("; ");
addOrder({
category: "laundry",
title: "Laundry · " + (lines.length ? `${lines.length} item type(s)` : "Express only"),
detail,
totalUsd: Math.round(subtotal * 100) / 100,
status: "pending",
});
setQty({});
setExpress(false);
setSent(true);
// Build items Json
const buildItems = (): LaundryCartItem[] =>
Object.entries(cart)
.filter(([, qty]) => qty > 0)
.map(([label, quantity]) => ({ label: laundryItems.find(i => i.label.toLowerCase() === label.toLowerCase())?.label || label, quantity }));
async function submit() {
if (!canUseApi || buildItems().length === 0) {
setSubmitErr("Please select at least one item.");
return;
}
setSubmitErr(null);
setSubmitting(true);
try {
await guestPlaceLaundry(propertyId!, accessToken!, {
bookingId: bookingId!,
items: buildItems(),
sameDay,
total: displayTotal,
// currency: "ETB",
notes: notes.trim() || undefined,
pickupAt: pickupAt || undefined,
deliverAt: deliverAt || undefined,
});
setSubmitting(false);
setCart({});
setSameDay(false);
setPickupAt("");
setDeliverAt("");
setNotes("");
setSent(true);
} catch (e) {
setSubmitErr(e instanceof Error ? e.message : "Could not submit laundry request");
setSubmitting(false);
}
}
const needBooking = !bookingLoading && !bookingId;
const hasItems = buildItems().length > 0;
const updateQty = (label: string, delta: number) => {
setCart(prev => {
const current = prev[label] || 0;
const newQty = Math.max(0, current + delta);
const newCart = { ...prev };
if (newQty === 0) delete newCart[label];
else newCart[label] = newQty;
return newCart;
});
};
return (
<div className="bg-[var(--color-bg)] pb-24 pt-8 md:pt-12">
<div className="mx-auto max-w-7xl px-4 md:px-8">
<nav className="text-xs font-medium text-[var(--color-muted)]">
<Link href="/" className="hover:text-[var(--color-accent)]">
Home
</Link>
<Link href="/" className="hover:text-[var(--color-accent)]">Home</Link>
<span className="mx-2 opacity-50">/</span>
<Link href="/guest" className="hover:text-[var(--color-accent)]">
Guest hub
</Link>
<Link href="/guest" className="hover:text-[var(--color-accent)]">Guest hub</Link>
<span className="mx-2 opacity-50">/</span>
<span className="text-[var(--color-text)]">Laundry</span>
</nav>
<div className="mt-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
Laundry service
</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">
Select pieces and optional express surcharge. Mock request pickup at reception.
</p>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">Laundry service</h1>
<p className="mt-2 max-w-xl text-sm text-[var(--color-muted)]">Submit a laundry request attached to your active booking.</p>
</div>
<Link href="/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>
{sent ? (
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
Request logged (demo). Our team will confirm timing by phone.
{needBooking ? (
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
Sign in with a booking code or use a reservation to sync laundry with the hotel.
</div>
) : null}
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
<div className="space-y-2">
{laundryItems.map((row) => (
<div
key={row.id}
className="flex flex-col gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
{submitErr ? (
<div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">{submitErr}</div>
) : null}
{sent ? (
<div className="mt-6 rounded-2xl border border-[var(--color-accent)]/40 bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-primary)]">
Laundry request submitted successfully.
</div>
) : null}
{!sent && (
<div className="mt-10 grid gap-10 lg:grid-cols-[1fr_380px]">
{/* Items Selection */}
<div className="space-y-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm">
<label className="text-base font-semibold text-[var(--color-text)] block mb-2">Select items</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{laundryItems.map((item) => {
const qty = cart[item.label.toLowerCase()] || 0;
return (
<div key={item.label} className="border border-[var(--color-border)] rounded-xl p-4 bg-[var(--color-surface-muted)]">
<div className="font-medium text-[var(--color-text)] mb-1">{item.label}</div>
<div className="text-sm text-[var(--color-muted)] mb-3">{formatEtb(item.price)} / each</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => 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>
<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>
<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 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>
<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>
))}
<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>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Special instructions (e.g., no starch, delicate)..."
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3 py-2 text-sm"
/>
<button
type="button"
onClick={submit}
disabled={lines.length === 0 && !express}
className="btn-mustard mt-6 w-full justify-center py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
disabled={submitting || !hasItems || !canUseApi}
className="w-full rounded-xl bg-[var(--color-accent)] px-6 py-3 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Submit laundry request
{submitting ? "Submitting..." : `Place laundry order (${formatEtb(displayTotal)})`}
</button>
</div>
</aside>
</div>
</div>
)}
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,20 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext";
import type { OrderCategory, OrderRecord } from "@/context/AuthContext";
import {
seedAppointments,
seedRewardsHistory,
seedShuttle,
} from "@/lib/mocks/guestData";
import { siteConfig } from "@/lib/mocks/site";
guestMe,
guestOrders,
guestPointsHistory,
guestSpaBookings,
guestShuttles,
type PointLedgerRow,
type SpaBookingRow,
type ShuttleRow,
} from "@/lib/guest-hotel-api";
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
{ id: "all", label: "All" },
@ -60,13 +64,80 @@ export function ProfilePageClient() {
}
function ProfileContent() {
const { session, orders, logout } = useAuth();
const { session, logout, accessToken } = 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 orders;
return orders.filter((o) => o.category === orderFilter);
}, [orders, orderFilter]);
if (orderFilter === "all") return apiOrders;
return apiOrders.filter((o) => o.category === orderFilter);
}, [apiOrders, orderFilter]);
if (!session) {
return null;
@ -86,24 +157,19 @@ function ProfileContent() {
<div className="mt-6 flex flex-col gap-4 border-b border-[var(--color-border)] pb-8 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
{session.kind === "member"
? `Hello, ${session.displayName}`
: `Welcome, ${session.guestName}`}
Hello, {session.displayName}
</h1>
<p className="mt-2 text-sm text-[var(--color-muted)]">
{session.kind === "member" ? (
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{session.bookingCode ? (
<>
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{" · "}
Signed in via {session.authMethod}
Booking code{" "}
<span className="font-mono font-semibold text-[var(--color-text)]">
{session.bookingCode}
</span>
</>
) : (
<>
Booking <span className="font-mono font-semibold">{session.bookingRef}</span>
{" · "}
{session.roomLabel} · checkout {session.checkOut}
</>
)}
) : null}
</p>
</div>
<div className="flex flex-wrap gap-2">
@ -125,75 +191,82 @@ function ProfileContent() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Rewards points
</p>
{session.kind === "member" ? (
<>
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{session.points.toLocaleString()}
</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{session.tier} tier · earn on stays & dining
</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 className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{(apiBalance ?? session.points).toLocaleString()}
</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>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{apiBalance != null ? "Balance" : "Balance unavailable"}
</p>
</>
</div>
</div>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
<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>
{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>
)}
</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 including demo history and new orders from this
device.
Room service, laundry, gym, and spa
</p>
<div className="mt-4 flex flex-wrap gap-2">
{orderTabs.map((t) => (
@ -225,21 +298,33 @@ function ProfileContent() {
</section>
<section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards earned</h2>
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards history</h2>
<ul className="mt-4 space-y-2">
{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>
))}
{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}
</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,16 +1,15 @@
"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 (
<CurrencyProvider>
<AuthProvider>
<BookingProvider>{children}</BookingProvider>
</AuthProvider>
</CurrencyProvider>
<SessionProvider refetchOnWindowFocus>
<StoreHydration />
<AuthProvider>{children}</AuthProvider>
</SessionProvider>
);
}

View File

@ -6,8 +6,9 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useBooking } from "@/context/BookingContext";
import { useCurrency } from "@/context/CurrencyContext";
import { formatEtb } from "@/lib/format-etb";
import { formatArrivalTimeDisplay } from "@/lib/formatArrivalTime";
import { siteConfig } from "@/lib/mocks/site";
import { siteConfig } from "@/lib/site-config";
export default function ReserveHeldPage() {
const router = useRouter();
@ -60,14 +61,16 @@ export default function ReserveHeldPage() {
</h1>
<p className="mt-3 text-center text-sm text-[var(--color-muted)]">
{guest.firstName}, your room is saved finish payment whenever you&apos;re ready in this
browser session. (Demo: no real hold or email.)
browser session.
</p>
<p className="mt-2 text-center font-mono text-sm text-[var(--color-text)]">
Hold ref: {holdReference}
Booking code: {holdReference}
</p>
<p className="mt-2 text-center text-xs text-[var(--color-muted)]">
Indicative total when you pay:{" "}
<span className="font-semibold text-[var(--color-text)]">{formatUsd(total)}</span>
<span className="font-semibold text-[var(--color-text)]">
{selectedRoom.priceCurrency === "ETB" ? formatEtb(total) : formatUsd(total)}
</span>
</p>
<div className="mt-10 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] text-left shadow-sm">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,12 @@
"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/mocks/site";
import { siteConfig } from "@/lib/site-config";
import { useAuth } from "@/context/AuthContext";
const nav = [
{ href: "/#rooms", label: "Rooms" },
@ -16,6 +19,8 @@ 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">
@ -72,12 +77,14 @@ export function Header() {
</nav>
<div className="flex shrink-0 items-center gap-2 md:gap-3">
<HeaderAccount />
<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>
{!session && (
<Link
href="/booking"
className="btn-mustard px-4 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)] md:px-5"
>
Book
</Link>
)}
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,166 +4,111 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { DEMO_BOOKING_REFS } from "@/lib/mocks/guestData";
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";
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 { OrderCategory, OrderRecord } from "@/types/guest-order";
export type MemberSession = {
kind: "member";
accessToken: string;
email: string;
displayName: string;
propertyId?: string;
points: number;
tier: "Gold" | "Silver";
/** How they signed in — for display only */
authMethod: "otp" | "password" | "google" | "apple" | "facebook";
authMethod: "otp" | "password" | "google";
bookingCode?: string | null;
bookingId?: string | null;
role?: string;
};
export type BookingRefSession = {
kind: "bookingRef";
bookingRef: string;
guestName: string;
roomLabel: string;
checkOut: string;
};
export type GuestSession = MemberSession | BookingRefSession;
export type GuestSession = MemberSession;
type AuthContextValue = {
session: GuestSession | null;
orders: OrderRecord[];
isHydrated: boolean;
/** Demo OTP is always 123456 */
accessToken: string | null;
requestOtp: (email: string) => Promise<{ ok: boolean; message: string }>;
verifyOtp: (email: string, code: string) => Promise<{ ok: boolean; message: string }>;
loginPassword: (email: string, password: string) => Promise<{ ok: boolean; message: string }>;
loginSocial: (provider: "google" | "apple" | "facebook") => void;
loginBookingRef: (ref: string) => { ok: boolean; message: string };
logout: () => void;
loginGoogle: () => Promise<void>;
loginBookingRef: (ref: string) => Promise<{ ok: boolean; message: string }>;
logout: () => Promise<void>;
addOrder: (o: Omit<OrderRecord, "id" | "placedAt" | "status"> & { status?: OrderRecord["status"] }) => void;
awardPoints: (points: number) => void;
setOrders: (orders: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
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 [session, setSession] = useState<GuestSession | null>(null);
const [orders, setOrders] = useState<OrderRecord[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const { data, status } = useSession();
const orders = useOrdersStore((s) => s.orders);
const localBonusPoints = useGuestUiStore((s) => s.localBonusPoints);
useEffect(() => {
setSession(loadSession());
setOrders(loadOrders());
setIsHydrated(true);
}, []);
const isHydrated = status !== "loading";
const guestSession = useMemo((): GuestSession | null => {
if (status !== "authenticated" || !data?.accessToken) return null;
const email = data.user?.email ?? "";
const displayName = data.user?.name ?? email.split("@")[0] ?? "Guest";
return {
kind: "member",
accessToken: data.accessToken,
email,
displayName: displayName.charAt(0).toUpperCase() + displayName.slice(1),
propertyId: data.propertyId ?? getHotelPropertyId(),
points: localBonusPoints,
authMethod: data.authMethod ?? "password",
bookingCode: data.bookingCode ?? null,
bookingId: data.bookingId ?? null,
role: data.role,
};
}, [status, data, localBonusPoints]);
const requestOtp = useCallback(async (email: string) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { ok: false, message: "Enter a valid email address." };
}
return { ok: true, message: "Demo code sent. Use OTP 123456 to continue." };
const propertyId = getHotelPropertyId();
if (!propertyId) {
return { ok: false, message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID)." };
}
try {
const base = getPublicApiUrl();
const res = await fetch(`${base}/auth/hotel-guest/send-otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ propertyId, email: email.trim().toLowerCase() }),
});
const body = (await res.json().catch(() => ({}))) as { message?: string };
if (!res.ok) {
return {
ok: false,
message: typeof body.message === "string" ? body.message : "Could not send code.",
};
}
return { ok: true, message: "Check your email for the one-time code." };
} catch {
return { ok: false, message: "Could not send code. Try again." };
}
}, []);
const verifyOtp = useCallback(async (email: string, code: string) => {
const trimmed = code.replace(/\s/g, "");
if (trimmed !== "123456") {
return { ok: false, message: "Invalid code. Demo OTP is 123456." };
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 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." };
}, []);
@ -171,69 +116,47 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!email || !password) {
return { ok: false, message: "Email and password required." };
}
if (password !== "shitaye" && password !== "demo123") {
return {
ok: false,
message: "Incorrect password. Try demo password: shitaye",
};
const r = await signIn("credentials", {
identifier: email.trim().toLowerCase(),
password,
redirect: false,
});
if (r?.error) {
return { ok: false, message: "Invalid email or password." };
}
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 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 loginGoogle = useCallback(async () => {
await signIn("google", { callbackUrl: "/", redirect: true });
}, []);
const loginBookingRef = useCallback((ref: string) => {
const key = ref.trim().toUpperCase();
const row = DEMO_BOOKING_REFS[key];
if (!row) {
const loginBookingRef = useCallback(async (ref: string) => {
const propertyId = getHotelPropertyId();
if (!propertyId) {
return {
ok: false,
message: "Reference not found. Try SHITAYE-2026-DEMO or GUEST-1234.",
message: "Hotel is not configured (missing NEXT_PUBLIC_HOTEL_PROPERTY_ID).",
};
}
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 r = await signIn("booking-code", {
bookingCode: ref.trim(),
propertyId,
redirect: false,
});
if (r?.error) {
return { ok: false, message: "Invalid booking code or account not linked." };
}
return { ok: true, message: "Signed in with your booking." };
}, []);
const logout = useCallback(() => {
setSession(null);
persistSession(null);
const logout = useCallback(async () => {
useGuestUiStore.getState().resetLocalBonus();
await signOut({ callbackUrl: "/" });
}, []);
const setOrders = useCallback((next: OrderRecord[] | ((prev: OrderRecord[]) => OrderRecord[])) => {
useOrdersStore.getState().setOrders(next);
}, []);
const addOrder = useCallback(
@ -248,59 +171,49 @@ export function AuthProvider({ children }: { children: ReactNode }) {
placedAt: new Date().toISOString(),
status: o.status ?? "pending",
};
setOrders((prev) => {
const next = [rec, ...prev];
persistOrders(next);
return next;
});
if (session?.kind === "member") {
useOrdersStore.getState().pushOrder(rec);
if (guestSession?.kind === "member") {
const bonus = Math.min(150, Math.round(o.totalUsd * 2));
setSession((s) => {
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + bonus };
persistSession(u);
return u;
});
useGuestUiStore.getState().addLocalBonus(bonus);
}
},
[session],
[guestSession],
);
const awardPoints = useCallback((points: number) => {
setSession((s) => {
if (!s || s.kind !== "member") return s;
const u = { ...s, points: s.points + points };
persistSession(u);
return u;
});
useGuestUiStore.getState().addLocalBonus(points);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session,
session: guestSession,
orders,
isHydrated,
accessToken: data?.accessToken ?? null,
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginGoogle,
loginBookingRef,
logout,
addOrder,
awardPoints,
setOrders,
}),
[
session,
guestSession,
orders,
isHydrated,
data?.accessToken,
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginGoogle,
loginBookingRef,
logout,
addOrder,
awardPoints,
setOrders,
],
);

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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