Compare commits

..

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

66 changed files with 1552 additions and 2496 deletions

View File

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

328
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,13 @@ export function LoginPageClient() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nextPath = searchParams.get("next") || "/profile"; const nextPath = searchParams.get("next") || "/profile";
const { requestOtp, verifyOtp, loginPassword, loginGoogle, loginBookingRef } = useAuth(); const {
requestOtp,
verifyOtp,
loginPassword,
loginSocial,
loginBookingRef,
} = useAuth();
const [tab, setTab] = useState<Tab>("otp"); const [tab, setTab] = useState<Tab>("otp");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -53,17 +59,15 @@ export function LoginPageClient() {
if (r.ok) router.push(nextPath); if (r.ok) router.push(nextPath);
} }
async function handleGoogle() { function handleSocial(provider: "google" | "apple" | "facebook") {
setMessage(null); loginSocial(provider);
await loginGoogle(); router.push(nextPath);
} }
async function handleBookingRef(e: React.FormEvent) { function handleBookingRef(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
setLoading(true); const r = loginBookingRef(bookingRef);
const r = await loginBookingRef(bookingRef);
setLoading(false);
setMessage(r.message); setMessage(r.message);
if (r.ok) router.push(nextPath); if (r.ok) router.push(nextPath);
} }
@ -158,7 +162,7 @@ export function LoginPageClient() {
/> />
</label> </label>
<p className="text-xs text-[var(--color-muted)]"> <p className="text-xs text-[var(--color-muted)]">
Use the code sent to your email (hotel guest OTP). Demo: enter <strong>123456</strong>
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@ -208,7 +212,7 @@ export function LoginPageClient() {
/> />
</label> </label>
<p className="text-xs text-[var(--color-muted)]"> <p className="text-xs text-[var(--color-muted)]">
Use your Yaltopia homes account password Demo password: <strong>shitaye</strong> or <strong>demo123</strong>
</p> </p>
<button <button
type="submit" type="submit"
@ -222,20 +226,32 @@ export function LoginPageClient() {
{tab === "social" && ( {tab === "social" && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-[var(--color-muted)]">
Mock sign-in no external redirect in this demo.
</p>
<button <button
type="button" type="button"
onClick={() => void handleGoogle()} onClick={() => handleSocial("google")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-3 text-sm font-semibold transition hover:bg-[var(--color-surface-muted)]" 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> <span className="text-base" aria-hidden>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"> G
<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> </span>
Login with Google Continue with Google
</button>
<button
type="button"
onClick={() => handleSocial("apple")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-text)] py-3 text-sm font-semibold text-white transition hover:opacity-90"
>
Continue with Apple
</button>
<button
type="button"
onClick={() => handleSocial("facebook")}
className="flex w-full items-center justify-center gap-2 rounded-full border border-[var(--color-border)] bg-[#1877f2] py-3 text-sm font-semibold text-white transition hover:opacity-95"
>
Continue with Facebook
</button> </button>
</div> </div>
)} )}
@ -253,15 +269,11 @@ export function LoginPageClient() {
/> />
</label> </label>
<p className="text-xs text-[var(--color-muted)]"> <p className="text-xs text-[var(--color-muted)]">
Enter the booking code from your confirmation email. You must have used the same Try <strong>SHITAYE-2026-DEMO</strong> or <strong>GUEST-1234</strong> no email
email at booking so your account links for the full guest portal. required. You can place orders and view a limited stay profile.
</p> </p>
<button <button type="submit" className="btn-mustard w-full justify-center py-3 text-sm">
type="submit" Continue with booking ID
disabled={loading}
className="btn-mustard w-full justify-center py-3 text-sm disabled:opacity-60"
>
{loading ? "Signing in…" : "Continue with booking code"}
</button> </button>
</form> </form>
)} )}

View File

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

View File

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

View File

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

View File

@ -1,20 +1,16 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { RequireAuth } from "@/components/RequireAuth"; import { RequireAuth } from "@/components/RequireAuth";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import type { OrderCategory, OrderRecord } from "@/context/AuthContext"; import type { OrderCategory, OrderRecord } from "@/context/AuthContext";
import { import {
guestMe, seedAppointments,
guestOrders, seedRewardsHistory,
guestPointsHistory, seedShuttle,
guestSpaBookings, } from "@/lib/mocks/guestData";
guestShuttles, import { siteConfig } from "@/lib/mocks/site";
type PointLedgerRow,
type SpaBookingRow,
type ShuttleRow,
} from "@/lib/guest-hotel-api";
const orderTabs: { id: OrderCategory | "all"; label: string }[] = [ const orderTabs: { id: OrderCategory | "all"; label: string }[] = [
{ id: "all", label: "All" }, { id: "all", label: "All" },
@ -64,80 +60,13 @@ export function ProfilePageClient() {
} }
function ProfileContent() { function ProfileContent() {
const { session, logout, accessToken } = useAuth(); const { session, orders, logout } = useAuth();
const [orderFilter, setOrderFilter] = useState<OrderCategory | "all">("all"); 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(() => { const filteredOrders = useMemo(() => {
if (orderFilter === "all") return apiOrders; if (orderFilter === "all") return orders;
return apiOrders.filter((o) => o.category === orderFilter); return orders.filter((o) => o.category === orderFilter);
}, [apiOrders, orderFilter]); }, [orders, orderFilter]);
if (!session) { if (!session) {
return null; return null;
@ -157,19 +86,24 @@ function ProfileContent() {
<div className="mt-6 flex flex-col gap-4 border-b border-[var(--color-border)] pb-8 md:flex-row md:items-end md:justify-between"> <div 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> <div>
<h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl"> <h1 className="font-heading text-3xl font-semibold text-[var(--color-text)] md:text-4xl">
Hello, {session.displayName} {session.kind === "member"
? `Hello, ${session.displayName}`
: `Welcome, ${session.guestName}`}
</h1> </h1>
<p className="mt-2 text-sm text-[var(--color-muted)]"> <p className="mt-2 text-sm text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text)]">{session.email}</span> {session.kind === "member" ? (
{session.bookingCode ? (
<> <>
<span className="font-medium text-[var(--color-text)]">{session.email}</span>
{" · "} {" · "}
Booking code{" "} Signed in via {session.authMethod}
<span className="font-mono font-semibold text-[var(--color-text)]">
{session.bookingCode}
</span>
</> </>
) : null} ) : (
<>
Booking <span className="font-mono font-semibold">{session.bookingRef}</span>
{" · "}
{session.roomLabel} · checkout {session.checkOut}
</>
)}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -191,26 +125,55 @@ function ProfileContent() {
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Rewards points Rewards points
</p> </p>
{session.kind === "member" ? (
<> <>
<p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]"> <p className="mt-3 font-heading text-4xl font-semibold text-[var(--color-text)]">
{(apiBalance ?? session.points).toLocaleString()} {session.points.toLocaleString()}
</p> </p>
<p className="mt-1 text-sm text-[var(--color-muted)]"> <p className="mt-1 text-sm text-[var(--color-muted)]">
{apiBalance != null ? "Balance" : "Balance unavailable"} {session.tier} tier · earn on stays & dining
</p> </p>
</> </>
) : (
<p className="mt-3 text-sm leading-relaxed text-[var(--color-muted)]">
Full loyalty points unlock when you sign in with email. Booking-ID access covers
orders and stay tools.
</p>
)}
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-sm lg:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)]">
Airport shuttle
</p>
<p className="mt-2 font-heading text-xl text-[var(--color-text)]">
Lobby pickup · {seedShuttle.lobbyPickupTime}
</p>
<p className="mt-2 text-sm text-[var(--color-muted)]">
{new Date(seedShuttle.departureDate).toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
})}{" "}
· {seedShuttle.airport}
</p>
<p className="mt-1 text-sm font-medium text-[var(--color-text)]">
{seedShuttle.flightLabel}
</p>
<p className="mt-3 text-xs text-[var(--color-muted)]">{seedShuttle.notes}</p>
<a
href={`mailto:${siteConfig.email}?subject=Shuttle%20change`}
className="mt-4 inline-block text-sm font-semibold text-[var(--color-accent)] hover:underline"
>
Request a change
</a>
</div> </div>
</div> </div>
<section className="mt-12"> <section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2> <h2 className="font-heading text-2xl text-[var(--color-text)]">Booked appointments</h2>
{appointments.length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-muted)] px-4 py-8 text-sm text-[var(--color-muted)]">
No gym/spa bookings found.
</p>
) : (
<ul className="mt-4 grid gap-3 md:grid-cols-2"> <ul className="mt-4 grid gap-3 md:grid-cols-2">
{appointments.map((a) => ( {seedAppointments.map((a) => (
<li <li
key={a.id} key={a.id}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm" className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-sm"
@ -218,55 +181,19 @@ function ProfileContent() {
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]"> <p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">
{a.status} {a.status}
</p> </p>
<p className="mt-2 font-semibold text-[var(--color-text)]"> <p className="mt-2 font-semibold text-[var(--color-text)]">{a.title}</p>
{a.offering?.name ?? "Spa/Gym booking"} <p className="mt-1 text-sm text-[var(--color-muted)]">{a.when}</p>
</p> <p className="mt-1 text-sm text-[var(--color-muted)]">{a.where}</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{formatWhen(a.scheduledAt ?? a.createdAt)}
</p>
</li> </li>
))} ))}
</ul> </ul>
)}
</section> </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"> <section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Orders</h2> <h2 className="font-heading text-2xl text-[var(--color-text)]">Orders</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]"> <p className="mt-1 text-sm text-[var(--color-muted)]">
Room service, laundry, gym, and spa Room service, laundry, gym, and spa including demo history and new orders from this
device.
</p> </p>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{orderTabs.map((t) => ( {orderTabs.map((t) => (
@ -298,33 +225,21 @@ function ProfileContent() {
</section> </section>
<section className="mt-12"> <section className="mt-12">
<h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards history</h2> <h2 className="font-heading text-2xl text-[var(--color-text)]">Rewards earned</h2>
<ul className="mt-4 space-y-2"> <ul className="mt-4 space-y-2">
{apiLedger.length > 0 {seedRewardsHistory.map((r) => (
? apiLedger.map((r) => (
<li <li
key={r.id} 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" className="flex items-center justify-between rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
> >
<div> <div>
<p className="font-medium text-[var(--color-text)]">{r.reason.replace(/_/g, " ")}</p> <p className="font-medium text-[var(--color-text)]">{r.label}</p>
<p className="text-xs text-[var(--color-muted)]">{formatWhen(r.createdAt)}</p> <p className="text-xs text-[var(--color-muted)]">{r.earnedAt}</p>
</div> </div>
<span <span className="badge-mustard">+{r.points} pts</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> </li>
)) ))}
: null}
</ul> </ul>
{apiLedger.length === 0 ? (
<p className="mt-4 text-sm text-[var(--color-muted)]">No rewards history returned yet.</p>
) : null}
</section> </section>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/mocks/site";
/** /**
* Google Maps embed (search result for the hotel). Uses the same pattern as * 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 }) { export function GoogleMapEmbed({ className = "" }: { className?: string }) {
return ( return (

View File

@ -1,12 +1,9 @@
"use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { HeaderAccount } from "@/components/HeaderAccount"; import { HeaderAccount } from "@/components/HeaderAccount";
import { CurrencySwitcher } from "@/components/CurrencySwitcher"; import { CurrencySwitcher } from "@/components/CurrencySwitcher";
import { ReviewsMenu } from "@/components/ReviewsMenu"; import { ReviewsMenu } from "@/components/ReviewsMenu";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/mocks/site";
import { useAuth } from "@/context/AuthContext";
const nav = [ const nav = [
{ href: "/#rooms", label: "Rooms" }, { href: "/#rooms", label: "Rooms" },
@ -19,8 +16,6 @@ const nav = [
]; ];
export function Header() { export function Header() {
const { session } = useAuth();
return ( return (
<header className="sticky top-0 z-40"> <header className="sticky top-0 z-40">
<div className="border-b border-white/10 bg-[var(--color-navy)] text-white"> <div className="border-b border-white/10 bg-[var(--color-navy)] text-white">
@ -77,14 +72,12 @@ export function Header() {
</nav> </nav>
<div className="flex shrink-0 items-center gap-2 md:gap-3"> <div className="flex shrink-0 items-center gap-2 md:gap-3">
<HeaderAccount /> <HeaderAccount />
{!session && (
<Link <Link
href="/booking" 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" 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 Book
</Link> </Link>
)}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

@ -0,0 +1,103 @@
export type Room = {
id: string;
slug: string;
name: string;
shortDescription: string;
longDescription: string;
nightlyRate: number;
maxGuests: number;
beds: string;
sizeSqM: number;
view: string;
highlights: string[];
gallery: string[];
tourEmbedUrl: string | null;
};
export const rooms: Room[] = [
{
id: "penthouse",
slug: "four-bedroom-penthouse",
name: "The 4 Bedroom Penthouse",
shortDescription: "Our flagship residence with panoramic views and full kitchenette.",
longDescription:
"Experience elevated living in our four-bedroom penthouse — expansive layouts, state-of-the-art kitchenette, and amazing views over Addis Ababa. Ideal for extended stays and distinguished guests who expect space, privacy, and premium finishes.",
nightlyRate: 485,
maxGuests: 8,
beds: "4 bedrooms — mix of king and twin configurations",
sizeSqM: 220,
view: "City skyline",
highlights: ["Private routers", "IPTV", "Mini bar", "In-room safe"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&q=80&auto=format&fit=crop",
"https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&q=80&auto=format&fit=crop",
],
tourEmbedUrl: null,
},
{
id: "standard",
slug: "standard-rooms",
name: "Standard Rooms",
shortDescription: "Refined comfort with every essential amenity.",
longDescription:
"Our standard rooms combine restful design with practical luxury: premium bedding, dedicated workspace, IPTV, and seamless WiFi / LAN. Perfect for business and leisure travellers who value consistency and calm.",
nightlyRate: 120,
maxGuests: 2,
beds: "1 King or 2 Twin",
sizeSqM: 28,
view: "City or courtyard",
highlights: ["B/B fast", "Iron & board", "Laundry (paid)", "Safe box"],
gallery: [
"https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&q=80",
"https://images.unsplash.com/photo-1566665797739-1674de7a215a?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "connecting-suite",
slug: "connecting-suite",
name: "Connecting Suite",
shortDescription: "Flexible suites — convert to a spacious family layout.",
longDescription:
"Connecting suite rooms with the option of converting to family suites. Enjoy separate living and sleeping zones, kitchenette access where applicable, and the same premium amenities found across the property.",
nightlyRate: 210,
maxGuests: 5,
beds: "1 King + connecting twin room",
sizeSqM: 55,
view: "City",
highlights: ["Family-friendly layout", "Kitchenette", "IPTV", "Shuttle"],
gallery: [
"https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&q=80",
"https://images.unsplash.com/photo-1591088398332-8a7791972843?w=1200&q=80",
],
tourEmbedUrl: null,
},
{
id: "junior-studio",
slug: "junior-studios",
name: "Junior Studios",
shortDescription: "Compact sophistication for solo travellers and short stays.",
longDescription:
"Junior studios offer a smart open plan with kitchenette, premium WiFi, IPTV, and efficient storage — designed for guests who want independence without sacrificing hotel service.",
nightlyRate: 95,
maxGuests: 2,
beds: "1 Queen",
sizeSqM: 32,
view: "Urban",
highlights: ["Kitchenette", "Mini bar", "Private router option"],
gallery: [
"https://images.unsplash.com/photo-1522771739844-6a9f6d5f14af?w=1200&q=80",
"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=1200&q=80",
],
tourEmbedUrl: null,
},
];
export function getRoomBySlug(slug: string): Room | undefined {
return rooms.find((r) => r.slug === slug);
}
export function getAllRoomSlugs(): string[] {
return rooms.map((r) => r.slug);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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