From 79d7be5023bb2cbb05fdfcd70d7dafa5bf21c68f Mon Sep 17 00:00:00 2001 From: steinhelge Date: Sun, 23 Nov 2025 20:25:05 +0100 Subject: [PATCH] Phase 3: MVP Frontend - Admin, Staff Scanner, and Guest View --- src/hospitality-web/package-lock.json | 412 +++++++++++++++++- src/hospitality-web/package.json | 10 +- src/hospitality-web/src/App.tsx | 81 ++-- src/hospitality-web/src/hooks/useEvents.ts | 37 ++ src/hospitality-web/src/hooks/useGroups.ts | 64 +++ src/hospitality-web/src/hooks/useProducts.ts | 27 ++ src/hospitality-web/src/hooks/useQrCode.ts | 39 ++ src/hospitality-web/src/lib/api.ts | 58 +++ src/hospitality-web/src/lib/types.ts | 99 +++++ .../src/pages/admin/EventDetailPage.tsx | 160 +++++++ .../src/pages/admin/EventsPage.tsx | 122 ++++++ .../src/pages/guest/GuestQrPage.tsx | 91 ++++ .../src/pages/staff/ScannerPage.tsx | 101 +++++ 13 files changed, 1259 insertions(+), 42 deletions(-) create mode 100644 src/hospitality-web/src/hooks/useEvents.ts create mode 100644 src/hospitality-web/src/hooks/useGroups.ts create mode 100644 src/hospitality-web/src/hooks/useProducts.ts create mode 100644 src/hospitality-web/src/hooks/useQrCode.ts create mode 100644 src/hospitality-web/src/lib/api.ts create mode 100644 src/hospitality-web/src/lib/types.ts create mode 100644 src/hospitality-web/src/pages/admin/EventDetailPage.tsx create mode 100644 src/hospitality-web/src/pages/admin/EventsPage.tsx create mode 100644 src/hospitality-web/src/pages/guest/GuestQrPage.tsx create mode 100644 src/hospitality-web/src/pages/staff/ScannerPage.tsx diff --git a/src/hospitality-web/package-lock.json b/src/hospitality-web/package-lock.json index 4dd1e79..a964dd2 100644 --- a/src/hospitality-web/package-lock.json +++ b/src/hospitality-web/package-lock.json @@ -8,12 +8,20 @@ "name": "hospitality-web", "version": "0.0.0", "dependencies": { + "@tanstack/react-query": "^5.90.10", + "axios": "^1.13.2", + "date-fns": "^4.1.0", + "html5-qrcode": "^2.3.8", + "lucide-react": "^0.554.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6" }, "devDependencies": { "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", + "@types/qrcode.react": "^1.0.5", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", @@ -1365,6 +1373,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz", + "integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", + "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1434,6 +1468,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/qrcode.react": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.5.tgz", + "integrity": "sha512-BghPtnlwvrvq8QkGa1H25YnN+5OIgCKFuQruncGWLGJYOzeSKiix/4+B9BtfKF2wf5ja8yfyWYA3OXju995G8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", @@ -1809,6 +1853,12 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", @@ -1847,6 +1897,17 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1922,6 +1983,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1990,6 +2064,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2004,6 +2090,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2026,6 +2121,16 @@ "dev": true, "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2051,6 +2156,29 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.259", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", @@ -2058,6 +2186,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2432,6 +2605,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2461,6 +2670,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2471,6 +2689,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2497,6 +2752,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2514,6 +2781,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2531,6 +2837,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2732,6 +3044,24 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.554.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz", + "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2756,6 +3086,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2968,6 +3319,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2978,6 +3335,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3030,6 +3396,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", + "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3133,6 +3537,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/src/hospitality-web/package.json b/src/hospitality-web/package.json index 87c3bbc..cbd6526 100644 --- a/src/hospitality-web/package.json +++ b/src/hospitality-web/package.json @@ -10,12 +10,20 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.90.10", + "axios": "^1.13.2", + "date-fns": "^4.1.0", + "html5-qrcode": "^2.3.8", + "lucide-react": "^0.554.0", + "qrcode.react": "^4.2.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6" }, "devDependencies": { "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", + "@types/qrcode.react": "^1.0.5", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/src/hospitality-web/src/App.tsx b/src/hospitality-web/src/App.tsx index 1c524cb..326932b 100644 --- a/src/hospitality-web/src/App.tsx +++ b/src/hospitality-web/src/App.tsx @@ -1,46 +1,47 @@ -import { useEffect, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; +import EventsPage from './pages/admin/EventsPage'; +import EventDetailPage from './pages/admin/EventDetailPage'; +import ScannerPage from './pages/staff/ScannerPage'; +import GuestQrPage from './pages/guest/GuestQrPage'; -interface Person { - id: string - firstName: string - lastName: string - email: string -} +const queryClient = new QueryClient(); function App() { - const [people, setPeople] = useState([]) - - useEffect(() => { - fetch('/api/people') - .then(res => res.json()) - .then(data => { - if (Array.isArray(data)) { - setPeople(data) - } - }) - .catch(err => console.error(err)) - }, []) - return ( -
-

Hospitality Admin

- -
-

People

- {people.length === 0 ? ( -

No people found.

- ) : ( -
    - {people.map(person => ( -
  • - {person.firstName} {person.lastName} ({person.email}) -
  • - ))} -
- )} -
-
- ) + + +
+ + +
+ + } /> + } /> + } /> + } /> + +
+
+
+
+ ); } -export default App +export default App; diff --git a/src/hospitality-web/src/hooks/useEvents.ts b/src/hospitality-web/src/hooks/useEvents.ts new file mode 100644 index 0000000..140eecc --- /dev/null +++ b/src/hospitality-web/src/hooks/useEvents.ts @@ -0,0 +1,37 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { eventsApi } from '../lib/api'; +import type { Event, CreateEventRequest } from '../lib/types'; + +export const useEvents = () => { + return useQuery({ + queryKey: ['events'], + queryFn: async () => { + const { data } = await eventsApi.getAll(); + return data as Event[]; + }, + }); +}; + +export const useEvent = (id: string) => { + return useQuery({ + queryKey: ['events', id], + queryFn: async () => { + const { data } = await eventsApi.getById(id); + return data as Event; + }, + enabled: !!id, + }); +}; + +export const useCreateEvent = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: CreateEventRequest) => { + const response = await eventsApi.create(data); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + }, + }); +}; diff --git a/src/hospitality-web/src/hooks/useGroups.ts b/src/hospitality-web/src/hooks/useGroups.ts new file mode 100644 index 0000000..28d75ad --- /dev/null +++ b/src/hospitality-web/src/hooks/useGroups.ts @@ -0,0 +1,64 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { groupsApi, peopleApi } from '../lib/api'; +import type { Group, CreateGroupRequest, CreatePersonRequest, AssignQuotaRequest } from '../lib/types'; + +export const useGroups = (eventId: string) => { + return useQuery({ + queryKey: ['groups', eventId], + queryFn: async () => { + const { data } = await groupsApi.getByEventId(eventId); + return data as Group[]; + }, + enabled: !!eventId, + }); +}; + +export const useGroup = (id: string) => { + return useQuery({ + queryKey: ['groups', id], + queryFn: async () => { + const { data } = await groupsApi.getById(id); + return data as Group; + }, + enabled: !!id, + }); +}; + +export const useCreateGroup = (eventId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: CreateGroupRequest) => { + const response = await groupsApi.create(eventId, data); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups', eventId] }); + }, + }); +}; + +export const useCreatePerson = (groupId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: CreatePersonRequest) => { + const response = await peopleApi.create(groupId, data); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups', groupId] }); + }, + }); +}; + +export const useAssignQuota = (personId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: AssignQuotaRequest) => { + const response = await peopleApi.assignQuota(personId, data); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['people', personId] }); + }, + }); +}; diff --git a/src/hospitality-web/src/hooks/useProducts.ts b/src/hospitality-web/src/hooks/useProducts.ts new file mode 100644 index 0000000..751b532 --- /dev/null +++ b/src/hospitality-web/src/hooks/useProducts.ts @@ -0,0 +1,27 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { productsApi } from '../lib/api'; +import type { Product, CreateProductRequest } from '../lib/types'; + +export const useProducts = (eventId: string) => { + return useQuery({ + queryKey: ['products', eventId], + queryFn: async () => { + const { data } = await productsApi.getByEventId(eventId); + return data as Product[]; + }, + enabled: !!eventId, + }); +}; + +export const useCreateProduct = (eventId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: CreateProductRequest) => { + const response = await productsApi.create(eventId, data); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products', eventId] }); + }, + }); +}; diff --git a/src/hospitality-web/src/hooks/useQrCode.ts b/src/hospitality-web/src/hooks/useQrCode.ts new file mode 100644 index 0000000..1a0833d --- /dev/null +++ b/src/hospitality-web/src/hooks/useQrCode.ts @@ -0,0 +1,39 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { qrCodeApi, transactionsApi } from '../lib/api'; +import type { Person, Quota, CreateTransactionRequest } from '../lib/types'; + +export const usePersonByQrCode = (qrCode: string) => { + return useQuery({ + queryKey: ['qr', qrCode], + queryFn: async () => { + const { data } = await qrCodeApi.getByQrCode(qrCode); + return data as Person; + }, + enabled: !!qrCode && qrCode.length > 0, + }); +}; + +export const useQuotasByQrCode = (qrCode: string) => { + return useQuery({ + queryKey: ['qr', qrCode, 'quotas'], + queryFn: async () => { + const { data } = await qrCodeApi.getQuotas(qrCode); + return data as Quota[]; + }, + enabled: !!qrCode && qrCode.length > 0, + }); +}; + +export const useCreateTransaction = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: CreateTransactionRequest) => { + const response = await transactionsApi.create(data); + return response.data; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['qr', variables.qrCode] }); + queryClient.invalidateQueries({ queryKey: ['qr', variables.qrCode, 'quotas'] }); + }, + }); +}; diff --git a/src/hospitality-web/src/lib/api.ts b/src/hospitality-web/src/lib/api.ts new file mode 100644 index 0000000..0d3f964 --- /dev/null +++ b/src/hospitality-web/src/lib/api.ts @@ -0,0 +1,58 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5163/api'; + +export const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Event API +export const eventsApi = { + getAll: () => api.get('/events'), + getById: (id: string) => api.get(`/events/${id}`), + create: (data: any) => api.post('/events', data), + update: (id: string, data: any) => api.put(`/events/${id}`, data), + delete: (id: string) => api.delete(`/events/${id}`), +}; + +// Group API +export const groupsApi = { + getByEventId: (eventId: string) => api.get(`/events/${eventId}/groups`), + getById: (id: string) => api.get(`/groups/${id}`), + create: (eventId: string, data: any) => api.post(`/events/${eventId}/groups`, data), + update: (id: string, data: any) => api.put(`/groups/${id}`, data), + delete: (id: string) => api.delete(`/groups/${id}`), +}; + +// People API +export const peopleApi = { + getById: (id: string) => api.get(`/people/${id}`), + create: (groupId: string, data: any) => api.post(`/groups/${groupId}/people`, data), + update: (id: string, data: any) => api.put(`/people/${id}`, data), + delete: (id: string) => api.delete(`/people/${id}`), + assignQuota: (personId: string, data: any) => api.post(`/people/${personId}/quotas`, data), +}; + +// Product API +export const productsApi = { + getByEventId: (eventId: string) => api.get(`/events/${eventId}/products`), + create: (eventId: string, data: any) => api.post(`/events/${eventId}/products`, data), + update: (id: string, data: any) => api.put(`/products/${id}`, data), + delete: (id: string) => api.delete(`/products/${id}`), +}; + +// QR Code API +export const qrCodeApi = { + getByQrCode: (qrCode: string) => api.get(`/qr/${qrCode}`), + getQuotas: (qrCode: string) => api.get(`/qr/${qrCode}/quotas`), +}; + +// Transaction API +export const transactionsApi = { + create: (data: any) => api.post('/transactions', data), + getByPersonId: (personId: string) => api.get(`/transactions/person/${personId}`), + getByEventId: (eventId: string) => api.get(`/transactions/event/${eventId}`), +}; diff --git a/src/hospitality-web/src/lib/types.ts b/src/hospitality-web/src/lib/types.ts new file mode 100644 index 0000000..347da9a --- /dev/null +++ b/src/hospitality-web/src/lib/types.ts @@ -0,0 +1,99 @@ +export enum ProductType { + Access = 0, + Drink = 1, + Meal = 2, + Special = 3, +} + +export interface Event { + id: string; + name: string; + startDate: string; + endDate: string; + location: string; + groupCount?: number; + productCount?: number; +} + +export interface Group { + id: string; + eventId: string; + name: string; + contactPersonName?: string; + contactEmail?: string; + peopleCount?: number; + people?: Person[]; +} + +export interface Person { + id: string; + groupId: string; + qrCode: string; + name: string; + email?: string; + phoneNumber?: string; + quotas?: Quota[]; +} + +export interface Product { + id: string; + eventId: string; + name: string; + type: ProductType; +} + +export interface Quota { + productId: string; + productName: string; + productType: string; + initialAmount: number; + usedAmount: number; + remainingAmount: number; +} + +export interface Transaction { + id: string; + personId: string; + personName: string; + productId: string; + productName: string; + amount: number; + timestamp: string; + staffId?: string; +} + +// Request types +export interface CreateEventRequest { + name: string; + startDate: string; + endDate: string; + location: string; +} + +export interface CreateGroupRequest { + name: string; + contactPersonName?: string; + contactEmail?: string; +} + +export interface CreatePersonRequest { + name: string; + email?: string; + phoneNumber?: string; +} + +export interface CreateProductRequest { + name: string; + type: ProductType; +} + +export interface CreateTransactionRequest { + qrCode: string; + productId: string; + amount: number; +} + +export interface AssignQuotaRequest { + productId: string; + initialAmount: number; +} diff --git a/src/hospitality-web/src/pages/admin/EventDetailPage.tsx b/src/hospitality-web/src/pages/admin/EventDetailPage.tsx new file mode 100644 index 0000000..36144fb --- /dev/null +++ b/src/hospitality-web/src/pages/admin/EventDetailPage.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useEvent } from '../../hooks/useEvents'; +import { useGroups, useCreateGroup, useCreatePerson } from '../../hooks/useGroups'; +import { useProducts, useCreateProduct } from '../../hooks/useProducts'; +import { ProductType, type CreateGroupRequest, type CreatePersonRequest, type CreateProductRequest } from '../../lib/types'; + +export default function EventDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data: event } = useEvent(id!); + const { data: groups } = useGroups(id!); + const { data: products } = useProducts(id!); + const createGroup = useCreateGroup(id!); + const createProduct = useCreateProduct(id!); + + const [showGroupForm, setShowGroupForm] = useState(false); + const [showProductForm, setShowProductForm] = useState(false); + const [groupForm, setGroupForm] = useState({ name: '' }); + const [productForm, setProductForm] = useState({ name: '', type: ProductType.Drink }); + + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + await createGroup.mutateAsync(groupForm); + setGroupForm({ name: '' }); + setShowGroupForm(false); + }; + + const handleCreateProduct = async (e: React.FormEvent) => { + e.preventDefault(); + await createProduct.mutateAsync(productForm); + setProductForm({ name: '', type: ProductType.Drink }); + setShowProductForm(false); + }; + + if (!event) return
Loading...
; + + return ( +
+
+

{event.name}

+

{event.location}

+
+ +
+ {/* Groups Section */} +
+
+

Groups

+ +
+ + {showGroupForm && ( +
+
+ setGroupForm({ ...groupForm, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> + setGroupForm({ ...groupForm, contactPersonName: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> + setGroupForm({ ...groupForm, contactEmail: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> + +
+
+ )} + +
+ {groups?.map((group) => ( +
+

{group.name}

+ {group.contactPersonName && ( +

{group.contactPersonName}

+ )} +

{group.peopleCount || 0} people

+
+ ))} +
+
+ + {/* Products Section */} +
+
+

Products

+ +
+ + {showProductForm && ( +
+
+ setProductForm({ ...productForm, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg" + /> + + +
+
+ )} + +
+ {products?.map((product) => ( +
+

{product.name}

+

{ProductType[product.type]}

+
+ ))} +
+
+
+
+ ); +} diff --git a/src/hospitality-web/src/pages/admin/EventsPage.tsx b/src/hospitality-web/src/pages/admin/EventsPage.tsx new file mode 100644 index 0000000..7ceca52 --- /dev/null +++ b/src/hospitality-web/src/pages/admin/EventsPage.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useEvents, useCreateEvent } from '../../hooks/useEvents'; +import type { CreateEventRequest } from '../../lib/types'; + +export default function EventsPage() { + const navigate = useNavigate(); + const { data: events, isLoading } = useEvents(); + const createEvent = useCreateEvent(); + const [showForm, setShowForm] = useState(false); + const [formData, setFormData] = useState({ + name: '', + startDate: '', + endDate: '', + location: '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const result = await createEvent.mutateAsync(formData); + setShowForm(false); + setFormData({ name: '', startDate: '', endDate: '', location: '' }); + navigate(`/events/${result.id}`); + } catch (error) { + console.error('Failed to create event:', error); + } + }; + + if (isLoading) return
Loading...
; + + return ( +
+
+

Events

+ +
+ + {showForm && ( +
+

Create New Event

+
+
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+
+ + setFormData({ ...formData, startDate: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + setFormData({ ...formData, endDate: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+
+ + setFormData({ ...formData, location: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+
+ )} + +
+ {events?.map((event) => ( +
navigate(`/events/${event.id}`)} + className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow cursor-pointer" + > +

{event.name}

+

{event.location}

+
+

{new Date(event.startDate).toLocaleDateString()}

+
+ {event.groupCount || 0} groups + {event.productCount || 0} products +
+
+
+ ))} +
+
+ ); +} diff --git a/src/hospitality-web/src/pages/guest/GuestQrPage.tsx b/src/hospitality-web/src/pages/guest/GuestQrPage.tsx new file mode 100644 index 0000000..3d26cc8 --- /dev/null +++ b/src/hospitality-web/src/pages/guest/GuestQrPage.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { usePersonByQrCode } from '../../hooks/useQrCode'; + +export default function GuestQrPage() { + const [qrCode, setQrCode] = useState(''); + const [showQr, setShowQr] = useState(false); + const { data: person } = usePersonByQrCode(qrCode); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setShowQr(true); + }; + + return ( +
+

Guest View

+ + {!showQr ? ( +
+
+
+ + setQrCode(e.target.value)} + placeholder="Your QR code (GUID)" + className="w-full px-4 py-3 border border-gray-300 rounded-lg text-lg" + required + /> +
+ +
+
+ ) : ( +
+ {person && ( + <> +

{person.name}

+ +
+
+ +
+
+ +

Your Quotas

+
+ {person.quotas?.map((quota) => ( +
+
+
+

{quota.productName}

+

{quota.productType}

+
+
+

{quota.remainingAmount}

+

of {quota.initialAmount} remaining

+
+
+
+
+
+
+ ))} +
+ + + + )} +
+ )} +
+ ); +} diff --git a/src/hospitality-web/src/pages/staff/ScannerPage.tsx b/src/hospitality-web/src/pages/staff/ScannerPage.tsx new file mode 100644 index 0000000..8fc6a2e --- /dev/null +++ b/src/hospitality-web/src/pages/staff/ScannerPage.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { usePersonByQrCode, useCreateTransaction } from '../../hooks/useQrCode'; + +export default function ScannerPage() { + const [qrCode, setQrCode] = useState(''); + const [selectedProduct, setSelectedProduct] = useState(''); + const { data: person, isLoading } = usePersonByQrCode(qrCode); + const createTransaction = useCreateTransaction(); + + const handleScan = (e: React.FormEvent) => { + e.preventDefault(); + // QR code is set, person data will load automatically + }; + + const handleTransaction = async (productId: string, amount: number) => { + try { + await createTransaction.mutateAsync({ + qrCode, + productId, + amount, + }); + alert('Transaction recorded!'); + } catch (error: any) { + alert(error.response?.data || 'Failed to record transaction'); + } + }; + + return ( +
+

Staff Scanner

+ +
+
+
+ + setQrCode(e.target.value)} + placeholder="Enter QR code (GUID)" + className="w-full px-4 py-3 border border-gray-300 rounded-lg text-lg focus:ring-2 focus:ring-blue-500" + /> +
+ +
+
+ + {isLoading &&
Loading...
} + + {person && ( +
+

{person.name}

+ {person.email &&

{person.email}

} + +

Available Quotas

+
+ {person.quotas?.map((quota) => ( +
+
+
+

{quota.productName}

+

{quota.productType}

+
+
+

{quota.remainingAmount}

+

of {quota.initialAmount}

+
+
+
+ + {quota.remainingAmount >= 2 && ( + + )} +
+
+ ))} +
+
+ )} +
+ ); +}