Compare commits
8 Commits
d5c7d56c11
...
f3e7896169
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e7896169 | |||
| c1f3461952 | |||
| 618d30aeef | |||
| 0160816b8e | |||
| bcc3a8de15 | |||
| c4748aa0ee | |||
| 429cdb7094 | |||
| aba29922c7 |
|
|
@ -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
328
package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
|
|
@ -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'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're ready.
|
||||
Pay later keeps your hold; you'll get a booking code. Payment is completed at the hotel unless
|
||||
you add card checkout later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 }> };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 “Add to selection” 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
|
|
|
|||
31
src/components/CatalogRoomsSection.tsx
Normal file
31
src/components/CatalogRoomsSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
28
src/components/RoomPrice.tsx
Normal file
28
src/components/RoomPrice.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
15
src/components/StoreHydration.tsx
Normal file
15
src/components/StoreHydration.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { siteConfig } from "@/lib/mocks/site";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { Mock3DPlaceholder } from "./Mock3DPlaceholder";
|
||||
import { VirtualTourEmbed } from "./VirtualTourEmbed";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
45
src/lib/api-client.ts
Normal 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
213
src/lib/auth-options.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
34
src/lib/data/laundryCatalog.ts
Normal file
34
src/lib/data/laundryCatalog.ts
Normal 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
10
src/lib/env.ts
Normal 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
7
src/lib/format-etb.ts
Normal 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
309
src/lib/guest-hotel-api.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
|
@ -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 Wi‑Fi / 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 Wi‑Fi, 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);
|
||||
}
|
||||
65
src/lib/public-hotel-api.ts
Normal file
65
src/lib/public-hotel-api.ts
Normal 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
46
src/lib/room-mapper.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
50
src/lib/useGuestActiveBooking.ts
Normal file
50
src/lib/useGuestActiveBooking.ts
Normal 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
262
src/stores/booking-store.ts
Normal 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]);
|
||||
}
|
||||
44
src/stores/currency-store.ts
Normal file
44
src/stores/currency-store.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
14
src/stores/guest-ui-store.ts
Normal file
14
src/stores/guest-ui-store.ts
Normal 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 }),
|
||||
}));
|
||||
18
src/stores/orders-store.ts
Normal file
18
src/stores/orders-store.ts
Normal 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
11
src/types/guest-order.ts
Normal 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
26
src/types/next-auth.d.ts
vendored
Normal 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
17
src/types/room.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user