From 8f31b806474ff300d49d1914e8b41908254d841e Mon Sep 17 00:00:00 2001 From: TopherMayor Date: Wed, 29 Apr 2026 21:29:20 -0700 Subject: [PATCH] feat: add cabo package planning and price watch assets --- README.md | 6 +- package-lock.json | 908 +++++++++++++++++++++++++++++++++ package.json | 19 + price-watch/.gitignore | 2 + price-watch/watch-targets.json | 44 ++ public/index.html | 219 +++++++- seed-data.js | 586 +++++++++++++++++++++ server.js | 479 +++++++---------- 8 files changed, 1951 insertions(+), 312 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 price-watch/.gitignore create mode 100644 price-watch/watch-targets.json create mode 100644 seed-data.js diff --git a/README.md b/README.md index a3a3813..52c191c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Real-time group voting for the bachelor party — hotels, golf, nightlife, excur ## Quick Start ```bash -cd voting_app +cd cabo-voting-app npm install node server.js # → http://localhost:3001 @@ -15,6 +15,7 @@ node server.js - **Real-time WebSocket voting** — all clients update instantly - **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries +- **Budget planner tab** — quick compare for 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks - **Add suggestions** — anyone can propose new venues - **Admin approval** — pending options require approval before going live - **Responsive** — works on desktop and mobile @@ -22,10 +23,11 @@ node server.js ## Data Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel. +System seed data auto-refreshes researched package options and budget scenarios while preserving existing votes and user-added options. ## Deployment -Deployed on `ice:3001` via Node.js directly (not Docker). Routed through Traefik on `ubuntu` via `cabo-voting.yml`. +Deployed on `ice:3001` via Node.js directly (not Docker). Routed through Traefik on `ubuntu` via the `cabo-voting.local.tophermayor.com` service route. See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eac1161 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,908 @@ +{ + "name": "cabo-voting-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cabo-voting-app", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.2", + "uuid": "^11.1.0", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/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/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/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/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a412b3 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "cabo-voting-app", + "version": "1.0.0", + "private": true, + "description": "Real-time Cabo bachelor party voting with budgets, packages, and activity planning.", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.2", + "uuid": "^11.1.0", + "ws": "^8.18.2" + } +} diff --git a/price-watch/.gitignore b/price-watch/.gitignore new file mode 100644 index 0000000..405b2f4 --- /dev/null +++ b/price-watch/.gitignore @@ -0,0 +1,2 @@ +latest-report.md +history.jsonl diff --git a/price-watch/watch-targets.json b/price-watch/watch-targets.json new file mode 100644 index 0000000..c4eb625 --- /dev/null +++ b/price-watch/watch-targets.json @@ -0,0 +1,44 @@ +{ + "reporting": { + "latestReport": "price-watch/latest-report.md", + "historyLog": "price-watch/history.jsonl" + }, + "comparison": { + "materialPriceChangeUsd": 100, + "highlightNewOptions": true, + "markLoginRequiredSources": true + }, + "trackedSources": [ + { + "id": "packages-hotels", + "label": "Packages and Hotels", + "categories": ["hotel"] + }, + { + "id": "golf", + "label": "Golf", + "categories": ["golf"] + }, + { + "id": "nightlife", + "label": "Nightlife and Day Clubs", + "categories": ["nightlife"] + }, + { + "id": "excursions", + "label": "Excursions and Water Activities", + "categories": ["excursion"] + }, + { + "id": "budget", + "label": "Budget Tracks", + "categories": ["budget"] + } + ], + "notes": [ + "Use seed-data.js as the current baseline for names, links, and budget assumptions.", + "Write a human-readable report to price-watch/latest-report.md on every run.", + "Append one machine-readable summary line per run to price-watch/history.jsonl.", + "If a source is gated behind login or membership, note that clearly in both outputs." + ] +} diff --git a/public/index.html b/public/index.html index ddeffb7..1c2423e 100644 --- a/public/index.html +++ b/public/index.html @@ -333,6 +333,7 @@ --nightlife: #a855f7; --excursion: #06b6d4; --itinerary: #fbbf24; + --budget: #f97316; } /* ── Reset ──────────────────────────────────────────────── */ @@ -596,6 +597,29 @@ margin-bottom: 8px; } .option-link:hover { opacity: 1; text-decoration: underline; } + .option-links { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + .option-pill { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid rgba(0, 212, 255, 0.2); + background: rgba(0, 212, 255, 0.08); + border-radius: 999px; + padding: 4px 8px; + font-size: 0.66rem; + color: var(--accent); + opacity: 0.92; + } + .option-pill:hover { + opacity: 1; + text-decoration: none; + border-color: rgba(0, 212, 255, 0.45); + } /* Vote bar */ .vote-bar-bg { @@ -615,6 +639,7 @@ .vote-bar-fill.nightlife { background: var(--nightlife); } .vote-bar-fill.excursion { background: var(--excursion); } .vote-bar-fill.itinerary { background: var(--itinerary); } + .vote-bar-fill.budget { background: var(--budget); } .voters-row { margin-top: 5px; @@ -637,6 +662,117 @@ font-size: 0.65rem; color: var(--text-muted); } + .budget-board { + margin-bottom: 16px; + background: + radial-gradient(circle at top right, rgba(249, 115, 22, 0.16), transparent 38%), + linear-gradient(135deg, rgba(251, 191, 36, 0.08), rgba(19, 22, 31, 0.95) 48%, rgba(6, 182, 212, 0.08)); + border: 1px solid rgba(249, 115, 22, 0.28); + border-radius: 18px; + padding: 18px; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24); + } + .budget-board h2 { + font-size: 1.05rem; + color: #ffd7b0; + margin-bottom: 6px; + } + .budget-board p { + font-size: 0.78rem; + color: #e1c9b8; + line-height: 1.5; + } + .budget-stamp { + display: inline-flex; + margin-top: 10px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(249, 115, 22, 0.12); + color: #ffbe88; + font-size: 0.68rem; + letter-spacing: 0.3px; + } + .budget-grid { + margin: 16px 0 18px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; + } + .budget-card { + background: rgba(11, 13, 20, 0.76); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 14px; + backdrop-filter: blur(10px); + } + .budget-card h3 { + font-size: 0.95rem; + margin-bottom: 4px; + color: #fff3e8; + } + .budget-card .budget-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + color: #fbc08c; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .budget-card .budget-price { + font-size: 1.5rem; + font-weight: 800; + color: #fff; + margin-bottom: 4px; + } + .budget-card .budget-total { + font-size: 0.72rem; + color: #ffcf9c; + margin-bottom: 10px; + } + .budget-card .budget-summary { + font-size: 0.76rem; + color: #d9e3f4; + line-height: 1.45; + margin-bottom: 10px; + } + .budget-card ul { + list-style: none; + display: grid; + gap: 5px; + } + .budget-card li { + font-size: 0.7rem; + color: #b7c0d4; + line-height: 1.4; + padding-left: 10px; + position: relative; + } + .budget-card li::before { + content: ''; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + position: absolute; + left: 0; + top: 0.55em; + opacity: 0.8; + } + .budget-tier { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + font-weight: 700; + font-size: 0.66rem; + letter-spacing: 0.3px; + } + .budget-tier.budget { background: rgba(52, 211, 153, 0.12); color: #7df0c0; } + .budget-tier.balanced { background: rgba(6, 182, 212, 0.12); color: #7fe7ff; } + .budget-tier.splurge { background: rgba(249, 115, 22, 0.14); color: #ffbe88; } /* ── Add Option ─────────────────────────────────────────── */ .add-section { @@ -1059,6 +1195,7 @@ + @@ -1082,6 +1219,8 @@ voterName: localStorage.getItem('cabo_voter_name') || '', categories: [], options: [], + budgetScenarios: [], + priceUpdatedAt: '', pollsOpen: true, totalVoters: 0, wsConnected: false, @@ -1163,6 +1302,8 @@ if (msg.type === 'init') { state.categories = msg.categories; state.options = msg.options; + state.budgetScenarios = msg.budgetScenarios || []; + state.priceUpdatedAt = msg.priceUpdatedAt || ''; state.pollsOpen = msg.pollsOpen; state.totalVoters = msg.totalVoters; renderTabs(); @@ -1170,7 +1311,7 @@ } else if (msg.type === 'vote_update') { msg.results.forEach(r => { const opt = state.options.find(o => o.id === r.id); - if (opt) { opt.votes = r.votes; opt.voters = r.voters; } + if (opt) { opt.votes = (r.voters || []).map(name => ({ name })); } }); render(); if (mapInitialized) mapRefreshMarkers(); @@ -1227,6 +1368,12 @@ badge.className = 'polls-badge ' + (state.pollsOpen ? 'open' : 'closed'); } + function getVoteEntries(opt) { + if (Array.isArray(opt.votes)) return opt.votes; + if (Array.isArray(opt.voters)) return opt.voters.map(name => ({ name })); + return []; + } + // ── Name modal ──────────────────────────────────────────── function submitName() { const name = document.getElementById('voterNameInput').value.trim(); @@ -1339,7 +1486,17 @@ const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank; const medalClass = rank === 1 ? 'gold' : rank === 2 ? 'silver' : rank === 3 ? 'bronze' : ''; const winner = rank === 1 ? 'winner' : ''; - const barColor = cat.id === 'hotel' ? 'var(--hotel)' : cat.id === 'golf' ? 'var(--golf)' : cat.id === 'nightlife' ? 'var(--nightlife)' : cat.id === 'excursion' ? 'var(--excursion)' : 'var(--itinerary)'; + const barColor = cat.id === 'hotel' + ? 'var(--hotel)' + : cat.id === 'golf' + ? 'var(--golf)' + : cat.id === 'nightlife' + ? 'var(--nightlife)' + : cat.id === 'excursion' + ? 'var(--excursion)' + : cat.id === 'budget' + ? 'var(--budget)' + : 'var(--itinerary)'; return `
${medal}
@@ -1369,23 +1526,30 @@ } // Sort by votes desc - const sorted = [...opts].sort((a, b) => b.votes.length - a.votes.length); - const maxVotes = sorted[0] ? sorted[0].votes.length : 1; + const sorted = [...opts].sort((a, b) => getVoteEntries(b).length - getVoteEntries(a).length); + const maxVotes = sorted[0] ? getVoteEntries(sorted[0]).length : 1; + const budgetBoard = activeTab === 'budget' ? renderBudgetBoard() : ''; - list.innerHTML = sorted.map(opt => { + list.innerHTML = budgetBoard + sorted.map(opt => { const catClass = opt.categoryId; - const votePct = maxVotes > 0 ? (opt.votes.length / maxVotes * 100) : 0; - const hasVoted = state.voterName && opt.votes.some(v => v.name === state.voterName); - const voteList = opt.votes.map(v => v.name).join(', '); + const voteEntries = getVoteEntries(opt); + const votePct = maxVotes > 0 ? (voteEntries.length / maxVotes * 100) : 0; + const hasVoted = state.voterName && voteEntries.some(v => v.name === state.voterName); + const voteList = voteEntries.map(v => v.name).join(', '); + const linkPills = opt.links && opt.links.length + ? `` + : (opt.url ? `🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}` : ''); return `
${opt.name}
-
${opt.votes.length} vote${opt.votes.length !== 1 ? 's' : ''}
+
${voteEntries.length} vote${voteEntries.length !== 1 ? 's' : ''}
${opt.desc ? `
${opt.desc}
` : ''} - ${opt.url ? `🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}` : ''} + ${linkPills} ${opt.details && opt.details.length ? `
${opt.details.map(d => `${d}`).join('')}
` : ''} @@ -1398,6 +1562,39 @@ }).join(''); } + function renderBudgetBoard() { + if (!state.budgetScenarios.length) return ''; + + const tierOrder = { Budget: 0, Balanced: 1, Splurge: 2 }; + const scenarios = [...state.budgetScenarios].sort((a, b) => { + if (a.groupSize !== b.groupSize) return a.groupSize - b.groupSize; + return (tierOrder[a.tier] ?? 99) - (tierOrder[b.tier] ?? 99); + }); + + return ` +
+

💸 Budget Cheat Sheet

+

These are planning numbers for the group to compare tracks quickly before anyone starts buying flights. They use current live price signals and bake in the shared-cost difference between 8, 10, and 12 guys.

+
Pricing research last refreshed ${state.priceUpdatedAt || 'recently'}
+
+ ${scenarios.map(scenario => ` +
+
+ ${scenario.groupSize} guys + ${scenario.tier} +
+

${scenario.tier} Track

+
$${scenario.perPerson.toLocaleString()}
+
$${scenario.groupTotal.toLocaleString()} group total
+
${scenario.summary}
+
    ${scenario.notes.map(note => `
  • ${note}
  • `).join('')}
+
+ `).join('')} +
+
+ `; + } + // ── Voting ──────────────────────────────────────────────── function toggleVote(optionId) { if (activeTab === 'results') return; // no voting on results tab @@ -1414,7 +1611,7 @@ const opt = state.options.find(o => o.id === optionId); if (!opt) return; - const alreadyVoted = opt.votes.some(v => v.name === state.voterName); + const alreadyVoted = getVoteEntries(opt).some(v => v.name === state.voterName); wsSend({ type: 'vote', optionId, voterName: state.voterName, remove: alreadyVoted }); showToast(alreadyVoted ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`); diff --git a/seed-data.js b/seed-data.js new file mode 100644 index 0000000..d85a7c7 --- /dev/null +++ b/seed-data.js @@ -0,0 +1,586 @@ +const SEED_VERSION = 2; +const PRICE_UPDATED_AT = '2026-04-29'; + +const CATEGORY_META = { + hotel: { emoji: '🏨', color: '#3b82f6' }, + golf: { emoji: '⛳', color: '#22c55e' }, + nightlife: { emoji: '🎧', color: '#a855f7' }, + excursion: { emoji: '🚤', color: '#06b6d4' }, + itinerary: { emoji: '🗺️', color: '#fbbf24' }, + budget: { emoji: '💸', color: '#f97316' }, + results: { emoji: '🏆', color: '#facc15' }, +}; + +const BUDGET_SCENARIOS = [ + { + id: 'budget-8', + tier: 'Budget', + groupSize: 8, + perPerson: 1405, + groupTotal: 11240, + summary: 'Corazon + Palmilla + one shared activity + one nightlife night', + notes: [ + 'Flight estimate: $350', + 'Hotel estimate: $450', + 'Golf: Palmilla from $130', + 'Activity: public sail / whale watch about $95', + 'Food + drinks buffer: $275', + 'Private round-trip transfer share: about $33', + ], + }, + { + id: 'budget-10', + tier: 'Budget', + groupSize: 10, + perPerson: 1398, + groupTotal: 13980, + summary: 'Corazon + Palmilla + one shared activity + one nightlife night', + notes: [ + 'Flight estimate: $350', + 'Hotel estimate: $450', + 'Golf: Palmilla from $130', + 'Activity: public sail / whale watch about $95', + 'Food + drinks buffer: $275', + 'Private round-trip transfer share: about $26', + ], + }, + { + id: 'budget-12', + tier: 'Budget', + groupSize: 12, + perPerson: 1392, + groupTotal: 16704, + summary: 'Corazon + Palmilla + one shared activity + one nightlife night', + notes: [ + 'Flight estimate: $350', + 'Hotel estimate: $450', + 'Golf: Palmilla from $130', + 'Activity: public sail / whale watch about $95', + 'Food + drinks buffer: $275', + 'Private round-trip transfer share: about $22', + ], + }, + { + id: 'balanced-8', + tier: 'Balanced', + groupSize: 8, + perPerson: 1688, + groupTotal: 13504, + summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night', + notes: [ + 'Flight estimate: $350', + 'All-inclusive stay: $850', + 'Golf: Cabo del Sol / similar from $180', + 'Sunset sail: about $125', + 'Nightlife + covers: $100', + 'Transfer + resort buffer: about $83', + ], + }, + { + id: 'balanced-10', + tier: 'Balanced', + groupSize: 10, + perPerson: 1681, + groupTotal: 16810, + summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night', + notes: [ + 'Flight estimate: $350', + 'All-inclusive stay: $850', + 'Golf: Cabo del Sol / similar from $180', + 'Sunset sail: about $125', + 'Nightlife + covers: $100', + 'Transfer + resort buffer: about $76', + ], + }, + { + id: 'balanced-12', + tier: 'Balanced', + groupSize: 12, + perPerson: 1677, + groupTotal: 20124, + summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night', + notes: [ + 'Flight estimate: $350', + 'All-inclusive stay: $850', + 'Golf: Cabo del Sol / similar from $180', + 'Sunset sail: about $125', + 'Nightlife + covers: $100', + 'Transfer + resort buffer: about $72', + ], + }, + { + id: 'splurge-8', + tier: 'Splurge', + groupSize: 8, + perPerson: 2484, + groupTotal: 19872, + summary: 'Breathless or Secrets + premium golf + private charter + VIP table', + notes: [ + 'Flight estimate: $400', + 'Upscale all-inclusive stay: $1250', + 'Premium golf: Quivira / similar about $250', + 'Private whale or charter share: about $188', + 'VIP nightlife share: about $213', + 'Transfers + premium dinner buffer: about $183', + ], + }, + { + id: 'splurge-10', + tier: 'Splurge', + groupSize: 10, + perPerson: 2346, + groupTotal: 23460, + summary: 'Breathless or Secrets + premium golf + private charter + VIP table', + notes: [ + 'Flight estimate: $400', + 'Upscale all-inclusive stay: $1250', + 'Premium golf: Quivira / similar about $250', + 'Private whale or charter share: about $150', + 'VIP nightlife share: about $170', + 'Transfers + premium dinner buffer: about $126', + ], + }, + { + id: 'splurge-12', + tier: 'Splurge', + groupSize: 12, + perPerson: 2289, + groupTotal: 27468, + summary: 'Breathless or Secrets + premium golf + private charter + VIP table', + notes: [ + 'Flight estimate: $400', + 'Upscale all-inclusive stay: $1250', + 'Premium golf: Quivira / similar about $250', + 'Private whale or charter share: about $125', + 'VIP nightlife share: about $142', + 'Transfers + premium dinner buffer: about $122', + ], + }, +]; + +function createOption(option) { + const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888'; + const primaryUrl = option.links?.[0]?.url || option.url || null; + return { + approved: true, + addedBy: 'system', + votes: [], + details: [], + links: [], + categoryColor, + url: primaryUrl, + ...option, + categoryColor, + url: primaryUrl, + }; +} + +function buildSeedData() { + return { + seedVersion: SEED_VERSION, + priceUpdatedAt: PRICE_UPDATED_AT, + categories: [ + { id: 'hotel', name: 'Hotels', emoji: '🏨' }, + { id: 'golf', name: 'Golf', emoji: '⛳' }, + { id: 'nightlife', name: 'Nightlife', emoji: '🎧' }, + { id: 'excursion', name: 'Excursions', emoji: '🚤' }, + { id: 'itinerary', name: 'Itineraries', emoji: '🗺️' }, + { id: 'budget', name: 'Budget', emoji: '💸' }, + { id: 'results', name: 'Results', emoji: '🏆' }, + ], + budgetScenarios: BUDGET_SCENARIOS, + options: [ + createOption({ + id: 'hotel-corazon', + seedKey: 'hotel-corazon', + categoryId: 'hotel', + name: 'Corazon Cabo Resort & Spa', + desc: 'Best party-first base on Medano Beach. Walkable to downtown and Costco package pages currently show transfer-inclusive offers plus 4th or 5th night promos.', + lat: 23.0639, + lng: -109.6991, + details: ['KAYAK recent rooms $173-$551/night', 'Costco package', 'Walk to marina nightlife'], + links: [ + { label: 'Official', url: 'https://www.corazoncabo.com/' }, + { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' }, + { label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Cabo-Villas-Beach-Resort-Spa.58565.ksp' }, + ], + }), + createOption({ + id: 'hotel-breathless', + seedKey: 'hotel-breathless', + categoryId: 'hotel', + name: 'Breathless Cabo San Lucas', + desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.', + lat: 23.0628, + lng: -109.6981, + details: ['Apple Vacations from $942 pp / 3 nights', 'KAYAK from $393/night', 'Adults-only'], + links: [ + { label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' }, + { label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' }, + { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' }, + { label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=3&mode=0&onsaleid=1398047&traveldate=2026-05-10' }, + ], + }), + createOption({ + id: 'hotel-grand-fiesta', + seedKey: 'hotel-grand-fiesta', + categoryId: 'hotel', + name: 'Grand Fiesta Americana Los Cabos', + desc: 'Best overall balance for golf + all-inclusive + quality. Strong fit if the group wants one easy answer without going full splurge.', + lat: 23.0949, + lng: -109.7067, + details: ['Apple Vacations from $859 pp / 3 nights', 'KAYAK from $209/night', 'Golf-friendly'], + links: [ + { label: 'Official', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' }, + { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' }, + { label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=3&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=&vendorcode=APV' }, + { label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' }, + ], + }), + createOption({ + id: 'hotel-secrets', + seedKey: 'hotel-secrets', + categoryId: 'hotel', + name: 'Secrets Puerto Los Cabos', + desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.', + lat: 23.0227, + lng: -109.7062, + details: ['CheapCaribbean from $885 pp / 3 nights', '4-night examples from $1,108 pp', 'Adults-only'], + links: [ + { label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' }, + { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' }, + { label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=3&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=&vendorcode=CCV' }, + { label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' }, + ], + }), + createOption({ + id: 'hotel-pacifica', + seedKey: 'hotel-pacifica', + categoryId: 'hotel', + name: 'Pueblo Bonito Pacifica', + desc: 'Luxury adults-only option with Quivira access. Best if the trip is really a premium golf weekend with nightlife as a side quest.', + lat: 23.0474, + lng: -109.7053, + details: ['Adults-only', 'Quivira access', 'Luxury retreat'], + links: [ + { label: 'Official', url: 'https://www.pueblobonito.com/resorts/pacifica?resort=4' }, + { label: 'Quivira FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' }, + ], + }), + createOption({ + id: 'golf-palmilla', + seedKey: 'golf-palmilla', + categoryId: 'golf', + name: 'Palmilla Golf Club', + desc: 'Best public price signal I found with transparent inclusions: green fee, shared cart, practice facilities, and bottled water.', + lat: 23.0519, + lng: -109.7058, + details: ['From $130 pp', 'Shared cart included', 'Strong budget-track pick'], + links: [ + { label: 'Cabo Villas Golf', url: 'https://www.cabovillas.com/golf/palmilla' }, + { label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' }, + ], + }), + createOption({ + id: 'golf-cabo-del-sol', + seedKey: 'golf-cabo-del-sol', + categoryId: 'golf', + name: 'Cabo del Sol', + desc: 'The most natural golf pairing for Grand Fiesta Americana and the balanced-track itinerary.', + lat: 23.0569, + lng: -109.6962, + details: ['Use about $180 as current planning number', 'Best balanced-track fit', 'Ocean-desert layout'], + links: [ + { label: 'Official', url: 'https://cabodelsol.com/' }, + { label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' }, + ], + }), + createOption({ + id: 'golf-quivira', + seedKey: 'golf-quivira', + categoryId: 'golf', + name: 'Quivira Golf Club', + desc: 'Premium golf move for the splurge weekend. Access is easiest through the Pueblo Bonito / Pacifica side of the destination.', + lat: 23.0403, + lng: -109.7221, + details: ['Use about $250 for planning', 'Pairs with Pacifica', 'Signature ocean holes'], + links: [ + { label: 'Official', url: 'https://www.quiviraloscabos.com/golf/' }, + { label: 'Pacifica FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' }, + ], + }), + createOption({ + id: 'golf-puerto-los-cabos', + seedKey: 'golf-puerto-los-cabos', + categoryId: 'golf', + name: 'Puerto Los Cabos Golf', + desc: 'Most natural pairing with Secrets Puerto Los Cabos if the group picks the upscale San Jose side.', + lat: 23.0308, + lng: -109.6964, + details: ['Convenient from Secrets', 'Upscale east-cape feel', 'Good alternative to Quivira'], + links: [ + { label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' }, + { label: 'Secrets Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' }, + ], + }), + createOption({ + id: 'nightlife-cabo-bash', + seedKey: 'nightlife-cabo-bash', + categoryId: 'nightlife', + name: 'Cabo Bash VIP Nightlife', + desc: 'Most turnkey option if you want the weekend hosted instead of DIY. They also coordinate yachts, villas, and day clubs.', + lat: 23.0627, + lng: -109.6989, + details: ['Gold package for 16 guests: $1,700', 'Concierge coordination', 'Strongest bachelor specialist'], + links: [ + { label: 'Bachelor Parties', url: 'https://www.cabobash.com/bachelor.html' }, + { label: 'Nightlife', url: 'https://www.cabobash.com/nightlife.html' }, + { label: 'Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' }, + ], + }), + createOption({ + id: 'nightlife-cabo-agency', + seedKey: 'nightlife-cabo-agency', + categoryId: 'nightlife', + name: 'The Cabo Agency VIP Tables', + desc: 'Best if you want to book specific tables instead of a full concierge weekend.', + lat: 23.0692, + lng: -109.6993, + details: ['Cabo Wabo VIP table $155 with $100 credit', 'Booth $400 with $300 credit', 'A la carte nightlife'], + links: [ + { label: 'VIP Tables', url: 'https://www.thecaboagency.com/cabo_vip_tables.php' }, + { label: 'Entertainment Packages', url: 'https://www.thecaboagency.com/cabo_entertainment_packages.php' }, + ], + }), + createOption({ + id: 'nightlife-taboo', + seedKey: 'nightlife-taboo', + categoryId: 'nightlife', + name: 'Taboo Beach Club', + desc: 'High-spend daytime flex. Better for a splashy afternoon than an all-weekend base.', + lat: 23.0637, + lng: -109.7001, + details: ['Pool island for 4: $884', 'Cabana for 8: $2,060', 'Use for splurge tier'], + links: [ + { label: 'Cabo Bash Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' }, + ], + }), + createOption({ + id: 'nightlife-mango-deck', + seedKey: 'nightlife-mango-deck', + categoryId: 'nightlife', + name: 'Mango Deck / Office Zone', + desc: 'Best lower-spend party zone if you want daytime chaos close to Medano Beach without burning the whole budget.', + lat: 23.0631, + lng: -109.6995, + details: ['Mango Deck deposit from $40 pp', 'Easy with Corazon', 'Budget-track friendly'], + links: [ + { label: 'Cabo Bash Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' }, + ], + }), + createOption({ + id: 'excursion-whale-public', + seedKey: 'excursion-whale-public', + categoryId: 'excursion', + name: 'Cabo Adventures Whale Watching', + desc: 'February is prime whale season, and this is the cleanest official public-tour price signal I found.', + lat: 23.0626, + lng: -109.7004, + details: ['From $76', 'Prime season in February', 'Dock fee and transport extras may apply'], + links: [ + { label: 'Official', url: 'https://www.cabo-adventures.com/en/' }, + { label: 'Whale Season Guide', url: 'https://www.visitloscabos.travel/blog/post/whale-watching-in-los-cabos-2025-the-ultimate-guide-to-an-unforgettable-season/' }, + ], + }), + createOption({ + id: 'excursion-whale-private', + seedKey: 'excursion-whale-private', + categoryId: 'excursion', + name: 'Private Whale Watching Charter', + desc: 'Best splurge-group activity because the per-person hit gets much better as the group size rises.', + lat: 23.0626, + lng: -109.7004, + details: ['From $1,504 total', 'About $188 pp at 8', 'About $125 pp at 12'], + links: [ + { label: 'Cabo Adventures Private Tours', url: 'https://www.cabo-adventures.com/en/tours/private-cabo/' }, + ], + }), + createOption({ + id: 'excursion-atv', + seedKey: 'excursion-atv', + categoryId: 'excursion', + name: 'ATV Desert Adventure', + desc: 'Classic bachelor-party activity with a real extra-fee caveat worth budgeting up front.', + lat: 23.0289, + lng: -109.6689, + details: ['About $78-$91', '$35 damage waiver per vehicle', 'Entrance fee noted at check-in'], + links: [ + { label: 'Tour Landing Page', url: 'https://www.cabo-adventures.com/en/tours/land-adventures/' }, + { label: 'ATV Details', url: 'https://www.cabo-adventures.com/en/tour/atv-desert-adventure/' }, + ], + }), + createOption({ + id: 'excursion-sail', + seedKey: 'excursion-sail', + categoryId: 'excursion', + name: 'Sunset Sail / Public Yacht Day', + desc: 'Best balanced activity if the group wants a solid Cabo moment without chartering the entire day.', + lat: 23.0634, + lng: -109.6978, + details: ['Public sail from $109', 'Sunset sail $124.50', 'Private 38 foot sailboat from $855.94'], + links: [ + { label: 'Cabo Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' }, + ], + }), + createOption({ + id: 'itinerary-budget', + seedKey: 'itinerary-budget', + categoryId: 'itinerary', + name: 'Budget Track: Corazon + Palmilla + Public Activity', + desc: 'Best option if the group wants a real Cabo bachelor trip while keeping the all-in number close to the low-$1.4k range before extra bar tabs.', + lat: 23.0639, + lng: -109.6991, + details: ['8 guys: about $1,405 pp', '10 guys: about $1,398 pp', '12 guys: about $1,392 pp'], + links: [ + { label: 'Corazon', url: 'https://www.corazoncabo.com/' }, + { label: 'Palmilla', url: 'https://www.cabovillas.com/golf/palmilla' }, + { label: 'Transfers', url: 'https://www.cabovillas.com/transportation' }, + ], + }), + createOption({ + id: 'itinerary-balanced', + seedKey: 'itinerary-balanced', + categoryId: 'itinerary', + name: 'Balanced Track: Grand Fiesta + Golf + Sail', + desc: 'Best all-around answer for a group that wants fewer logistics, a nice resort, and one clean golf day.', + lat: 23.0949, + lng: -109.7067, + details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'], + links: [ + { label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' }, + { label: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' }, + { label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' }, + ], + }), + createOption({ + id: 'itinerary-splurge', + seedKey: 'itinerary-splurge', + categoryId: 'itinerary', + name: 'Splurge Track: Breathless or Secrets + Premium Golf + VIP Night', + desc: 'Best if the weekend is really about going big once, with the budget climbing above $2.2k per person depending on group size.', + lat: 23.0628, + lng: -109.6981, + details: ['8 guys: about $2,484 pp', '10 guys: about $2,346 pp', '12 guys: about $2,289 pp'], + links: [ + { label: 'Breathless', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' }, + { label: 'Secrets', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' }, + { label: 'VIP Tables', url: 'https://www.thecaboagency.com/cabo_vip_tables.php' }, + ], + }), + createOption({ + id: 'itinerary-concierge', + seedKey: 'itinerary-concierge', + categoryId: 'itinerary', + name: 'Concierge Route: Cabo Bash / Cabo Agency', + desc: 'Best if the group wants to stop spreadsheeting and hand flights, villas, yachts, transfers, and nightlife to a specialist.', + lat: 23.0633, + lng: -109.6992, + details: ['Most turnkey', 'Great for split budgets', 'Request quote for final pricing'], + links: [ + { label: 'Cabo Bash', url: 'https://www.cabobash.com/bachelor.html' }, + { label: 'The Cabo Agency', url: 'https://www.thecaboagency.com/cabo_bachelor_party.php' }, + { label: 'Blue Desert Package', url: 'https://www.bluedesertcabo.com/activities/packages/bachelor-party-vacation-package/' }, + ], + }), + createOption({ + id: 'budget-option-budget', + seedKey: 'budget-option-budget', + categoryId: 'budget', + name: 'Budget Track', + desc: 'Corazon + one golf round + one public activity + one nightlife night. Best value if you want the trip fun but not financially reckless.', + details: ['8: $1,405 pp', '10: $1,398 pp', '12: $1,392 pp'], + links: [ + { label: 'Corazon', url: 'https://www.corazoncabo.com/' }, + { label: 'Palmilla', url: 'https://www.cabovillas.com/golf/palmilla' }, + ], + }), + createOption({ + id: 'budget-option-balanced', + seedKey: 'budget-option-balanced', + categoryId: 'budget', + name: 'Balanced Track', + desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.', + details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'], + links: [ + { label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' }, + { label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' }, + ], + }), + createOption({ + id: 'budget-option-splurge', + seedKey: 'budget-option-splurge', + categoryId: 'budget', + name: 'Splurge Track', + desc: 'Adults-only resort, premium golf, private charter, and VIP nightlife. This is the blowout version.', + details: ['8: $2,484 pp', '10: $2,346 pp', '12: $2,289 pp'], + links: [ + { label: 'Breathless', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' }, + { label: 'Private Tour', url: 'https://www.cabo-adventures.com/en/tours/private-cabo/' }, + ], + }), + ], + voters: [], + pollsOpen: true, + }; +} + +function mergeSeedData(existing = {}) { + const seed = buildSeedData(); + const existingOptions = Array.isArray(existing.options) ? existing.options : []; + const mergedSeedOptions = seed.options.map((seedOption) => { + const match = existingOptions.find((option) => ( + option.seedKey === seedOption.seedKey + || (option.addedBy === 'system' && option.categoryId === seedOption.categoryId && option.name === seedOption.name) + )); + + return { + ...seedOption, + votes: Array.isArray(match?.votes) ? match.votes : [], + approved: typeof match?.approved === 'boolean' ? match.approved : seedOption.approved, + addedBy: match?.addedBy || seedOption.addedBy, + }; + }); + + const preservedCustomOptions = existingOptions.filter((option) => { + if (option.addedBy === 'system') return false; + + return !mergedSeedOptions.some((seedOption) => ( + (seedOption.seedKey && option.seedKey === seedOption.seedKey) + || (seedOption.categoryId === option.categoryId && seedOption.name === option.name) + )); + }); + + const existingCategories = Array.isArray(existing.categories) ? existing.categories : []; + const preservedCustomCategories = existingCategories.filter( + (category) => !seed.categories.some((seedCategory) => seedCategory.id === category.id), + ); + + return { + ...existing, + seedVersion: seed.seedVersion, + priceUpdatedAt: seed.priceUpdatedAt, + categories: [...seed.categories, ...preservedCustomCategories], + budgetScenarios: seed.budgetScenarios, + options: [...mergedSeedOptions, ...preservedCustomOptions], + voters: Array.isArray(existing.voters) ? existing.voters : [], + pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true, + }; +} + +module.exports = { + SEED_VERSION, + PRICE_UPDATED_AT, + CATEGORY_META, + buildSeedData, + mergeSeedData, +}; diff --git a/server.js b/server.js index 52661c3..f9c74a6 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const http = require('http'); const path = require('path'); const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); +const { CATEGORY_META, buildSeedData, mergeSeedData } = require('./seed-data'); const app = express(); const server = http.createServer(app); @@ -16,241 +17,86 @@ const DATA_FILE = path.join(DATA_DIR, 'votes.json'); app.use(cors()); app.use(express.json()); -// Admin panel app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); app.use(express.static(path.join(__dirname, 'public'))); -// ── Data helpers ────────────────────────────────────────────── - function loadData() { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + if (!fs.existsSync(DATA_FILE)) { const seed = buildSeedData(); fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2)); return seed; } - return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); + + const existing = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); + const merged = mergeSeedData(existing); + + if (JSON.stringify(existing) !== JSON.stringify(merged)) { + fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2)); + } + + return merged; } -function saveData(data) { - fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); +function saveData(nextData) { + fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2)); +} + +function approvedOptionsWithVoteSummary() { + return data.options + .filter((option) => option.approved) + .map((option) => ({ + id: option.id, + votes: option.votes.length, + voters: option.votes.map((vote) => vote.name), + })); } function broadcast(payload) { const msg = JSON.stringify(payload); - wss.clients.forEach(client => { + wss.clients.forEach((client) => { if (client.readyState === 1) client.send(msg); }); } -// ── Category meta (shared with frontend for map colors) ──────── -const CATEGORY_META = { - hotel: { emoji: '🏨', color: '#3b82f6' }, - golf: { emoji: '⛳', color: '#22c55e' }, - nightlife: { emoji: '🎧', color: '#a855f7' }, - excursion: { emoji: '🚤', color: '#06b6d4' }, - itinerary: { emoji: '🗺️', color: '#fbbf24' }, -}; - -// ── Seed data ───────────────────────────────────────────────── - -function buildSeedData() { +function buildRealtimeSnapshot() { return { - categories: [ - { id: 'hotel', name: 'Hotels', emoji: '🏨' }, - { id: 'golf', name: 'Golf', emoji: '⛳' }, - { id: 'nightlife', name: 'Nightlife', emoji: '🎧' }, - { id: 'excursion', name: 'Excursions', emoji: '🚤' }, - { id: 'itinerary', name: 'Itineraries', emoji: '🗺️' }, - { id: 'results', name: 'Results', emoji: '🏆' }, - ], - options: [ - // Hotels - { - id: uuidv4(), categoryId: 'hotel', - name: 'Grand Fiesta Americana', categoryColor: '#3b82f6', - desc: 'Premium all-inclusive · Golf packages · 5⭐ · ~$223/night', - url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos', - lat: 23.0949, lng: -109.7067, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'hotel', - name: 'Hotel Riu Palace', categoryColor: '#3b82f6', - desc: 'High-energy beachfront · 5⭐ · ~$250/night', - url: 'https://www.riu.com/en/hotel/los-cabos/hotel-riu-palace-cabo-san-lucas/', - lat: 23.0731, lng: -109.6987, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'hotel', - name: 'Marquis Los Cabos', categoryColor: '#3b82f6', - desc: 'Luxury adults-only · Infinity pool · ~$300/night', - url: 'https://www.marquisloscabos.com', - lat: 23.0567, lng: -109.6934, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'hotel', - name: 'Pueblo Bonito Pacifica', categoryColor: '#3b82f6', - desc: 'Exclusive Quivira Golf Club access · Adults-only · ~$280/night', - url: 'https://www.pueblobonito.com/pacifica', - lat: 23.0489, lng: -109.6889, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'hotel', - name: 'ME Cabo', categoryColor: '#3b82f6', - desc: 'Adults-only · Buzzing beach club · ~$200/night', - url: 'https://www.mecabo.com', - lat: 23.0667, lng: -109.6967, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'hotel', - name: 'Hacienda Encantada', categoryColor: '#3b82f6', - desc: 'Condo-feel all-inclusive · 2BR Suites · ~$180/night', - url: 'https://www.haciendaencantada.com', - lat: 23.0289, lng: -109.6789, - addedBy: 'system', approved: true, votes: [] - }, - // Golf - { - id: uuidv4(), categoryId: 'golf', - name: 'Quivira Golf Club', categoryColor: '#22c55e', - desc: 'Jack Nicklaus signature · Ocean views · $250/round', - url: 'https://quiviraloscabos.com', - lat: 23.0344, lng: -109.6834, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'golf', - name: 'Cabo Del Sol Golf', categoryColor: '#22c55e', - desc: 'Desert-ocean layout · 18 holes · $180/round', - url: 'https://www.cabodelsol.com/golf', - lat: 23.0567, lng: -109.6934, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'golf', - name: 'Solmar Golf Links', categoryColor: '#22c55e', - desc: 'Seaside championship course · $160/round', - url: 'https://www.solmargolflinks.com', - lat: 23.0611, lng: -109.6978, - addedBy: 'system', approved: true, votes: [] - }, - // Nightlife - { - id: uuidv4(), categoryId: 'nightlife', - name: 'El Squid Roe', categoryColor: '#a855f7', - desc: '3 floors · $40–50 cover · Open til 4am', - url: 'https://www.elsquidroe.com', - lat: 23.0694, lng: -109.6994, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'nightlife', - name: 'Mandala Nightclub', categoryColor: '#a855f7', - desc: 'VIP tables · $50 cover · High-energy', - url: 'https://www.mandalacabo.com', - lat: 23.0700, lng: -109.7000, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'nightlife', - name: 'Cabo Wabo Cantina', categoryColor: '#a855f7', - desc: "Sammy Hagar's · Live music · $30 cover", - url: 'https://www.cabowabocantina.com', - lat: 23.0692, lng: -109.6992, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'nightlife', - name: 'Crush Nightspot', categoryColor: '#a855f7', - desc: 'Upscale lounge · Craft cocktails · ~$30 cover', - url: 'https://www.crushcabo.com', - lat: 23.0706, lng: -109.7006, - addedBy: 'system', approved: true, votes: [] - }, - // Excursions - { - id: uuidv4(), categoryId: 'excursion', - name: 'Private Yacht to The Arch', categoryColor: '#06b6d4', - desc: 'Quivira Yacht Club · $250/person · 2hr', - url: 'https://www.viator.com/tours/Los-Cabos/Private-Luxury-Yacht-Charter/d637-11242P30', - lat: 23.0467, lng: -109.6844, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'excursion', - name: 'Wild Canyon Adventure', categoryColor: '#06b6d4', - desc: 'Zipline · Bungee jump · $80/person', - url: 'https://www.viator.com/tours/Los-Cabos/Wild-Canyon-Adventure/d637-11166P4', - lat: 22.9989, lng: -109.6589, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'excursion', - name: 'ATV Desert Adventure', categoryColor: '#06b6d4', - desc: 'Cerro de la Zanta · $100/person · 3hr', - url: 'https://www.viator.com/tours/Los-Cabos/ATV-Desert-Adventure-from-Cabo-San-Lucas/d637-11166P1', - lat: 23.0289, lng: -109.6689, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'excursion', - name: 'Sunset Sail Cruise', categoryColor: '#06b6d4', - desc: 'Medano Beach departure · $80/person · 2hr', - url: 'https://www.viator.com/tours/Los-Cabos/Sunset-Sail-Cruise-from-Cabo-San-Lucas/d637-11166P6', - lat: 23.0634, lng: -109.6978, - addedBy: 'system', approved: true, votes: [] - }, - { - id: uuidv4(), categoryId: 'excursion', - name: 'Cabo Shark Dive', categoryColor: '#06b6d4', - desc: 'Cage-free shark encounter · $150/person', - url: 'https://www.viator.com/tours/Los-Cabos/Cabo-Shark-Dive/d637-11166P8', - lat: 23.0450, lng: -109.6870, - addedBy: 'system', approved: true, votes: [] - }, - // Itineraries (no specific coords — shown as route in sidebar) - { - id: uuidv4(), categoryId: 'itinerary', - name: 'Plan A — Multi-Activity Action', categoryColor: '#fbbf24', - desc: 'ATV · VIP Cabana · Quivira Golf · Yacht · $1,870–$1,920/person', - url: null, lat: 23.0650, lng: -109.6980, - addedBy: 'system', approved: true, votes: [], - details: ['Quivira Golf $250', 'Private Yacht $250', 'ATV $100', 'VIP Cabanas $80', 'Nightlife $45', 'Transfers $30', 'Hotel 5 nights ~$1,115'] - }, - { - id: uuidv4(), categoryId: 'itinerary', - name: 'Plan B — Flexible Drop-In', categoryColor: '#fbbf24', - desc: 'Staggered arrivals · Mix & match · $1,577–$1,870/person', - url: null, lat: 23.0667, lng: -109.6967, - addedBy: 'system', approved: true, votes: [], - details: ['ME Cabo 4 nights ~$1,000', 'Beach clubs $20–30/day', 'Flexible dining $200', 'Nightlife $40–60', 'Sunset cruise $80', 'Transfers $30'] - }, - { - id: uuidv4(), categoryId: 'itinerary', - name: 'Plan C — Budget Golf Bundle', categoryColor: '#fbbf24', - desc: 'Grand Fiesta Americana + Golf PKG · ~$1,600/person', - url: null, lat: 23.0949, lng: -109.7067, - addedBy: 'system', approved: true, votes: [], - details: ['GFA golf package 5 nights ~$900', 'Quivira + Cabo Del Sol $150', 'Office Beach Club $25', 'Mandala $50', 'Transfers $25'] - }, - ], - voters: [], - pollsOpen: true, + type: 'init', + pollsOpen: data.pollsOpen, + categories: data.categories, + options: data.options.filter((option) => option.approved), + results: approvedOptionsWithVoteSummary(), + totalVoters: data.voters.length, + budgetScenarios: data.budgetScenarios || [], + priceUpdatedAt: data.priceUpdatedAt || null, + }; +} + +function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) { + return { + id: uuidv4(), + seedKey: null, + categoryId, + name: name.trim(), + desc: (desc || '').trim(), + url: url ? url.trim() : null, + links: url ? [{ label: 'Website', url: url.trim() }] : [], + lat: lat || null, + lng: lng || null, + addedBy: voterName, + approved, + votes: [], + details: [], + categoryColor: CATEGORY_META[categoryId]?.color || '#888', }; } let data = loadData(); -// ── API Routes ─────────────────────────────────────────────── - app.get('/api/categories', (req, res) => { res.json(data.categories); }); @@ -258,76 +104,104 @@ app.get('/api/categories', (req, res) => { app.get('/api/options', (req, res) => { const { category, includeUnapproved } = req.query; let options = data.options; - if (category) options = options.filter(o => o.categoryId === category); - if (!includeUnapproved) options = options.filter(o => o.approved); + + if (category) options = options.filter((option) => option.categoryId === category); + if (!includeUnapproved) options = options.filter((option) => option.approved); + res.json(options); }); app.get('/api/results', (req, res) => { - const results = data.categories.map(cat => ({ - ...cat, + const results = data.categories.map((category) => ({ + ...category, options: data.options - .filter(o => o.approved && o.categoryId === cat.id) - .map(o => ({ ...o, voteCount: o.votes.length })) + .filter((option) => option.approved && option.categoryId === category.id) + .map((option) => ({ ...option, voteCount: option.votes.length })), })); - res.json({ pollsOpen: data.pollsOpen, results, totalVoters: data.voters.length }); + + res.json({ + pollsOpen: data.pollsOpen, + results, + totalVoters: data.voters.length, + budgetScenarios: data.budgetScenarios || [], + priceUpdatedAt: data.priceUpdatedAt || null, + }); +}); + +app.get('/api/budgets', (req, res) => { + res.json({ + updatedAt: data.priceUpdatedAt || null, + scenarios: data.budgetScenarios || [], + }); }); app.post('/api/vote', (req, res) => { const { optionId, voterName } = req.body; - if (!voterName || !optionId) return res.status(400).json({ error: 'Missing fields' }); - if (!data.pollsOpen) return res.status(403).json({ error: 'Polls are closed' }); - const option = data.options.find(o => o.id === optionId); - if (!option || !option.approved) return res.status(404).json({ error: 'Option not found' }); + if (!voterName || !optionId) { + return res.status(400).json({ error: 'Missing fields' }); + } + if (!data.pollsOpen) { + return res.status(403).json({ error: 'Polls are closed' }); + } - const prevVote = data.options.find(o => o.votes.some(v => v.name === voterName) && o.categoryId === option.categoryId); - if (prevVote) { - prevVote.votes = prevVote.votes.filter(v => v.name !== voterName); + const option = data.options.find((candidate) => candidate.id === optionId); + if (!option || !option.approved) { + return res.status(404).json({ error: 'Option not found' }); + } + + const previousVote = data.options.find((candidate) => ( + candidate.categoryId === option.categoryId + && candidate.votes.some((vote) => vote.name === voterName) + )); + + if (previousVote) { + previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName); } option.votes.push({ name: voterName, timestamp: Date.now() }); - if (!data.voters.find(v => v.name === voterName)) { + if (!data.voters.find((voter) => voter.name === voterName)) { data.voters.push({ name: voterName, joinedAt: Date.now() }); } saveData(data); - broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) }); + broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); res.json({ success: true, voteCount: option.votes.length }); }); app.delete('/api/vote/:optionId', (req, res) => { const { voterName } = req.body; - const option = data.options.find(o => o.id === req.params.optionId); + const option = data.options.find((candidate) => candidate.id === req.params.optionId); + if (!option) return res.status(404).json({ error: 'Not found' }); - option.votes = option.votes.filter(v => v.name !== voterName); + + option.votes = option.votes.filter((vote) => vote.name !== voterName); saveData(data); - broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) }); + broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); res.json({ success: true }); }); app.post('/api/options', (req, res) => { const { categoryId, name, desc, url, voterName, lat, lng } = req.body; - if (!categoryId || !name || !voterName) return res.status(400).json({ error: 'Missing required fields' }); - const category = data.categories.find(c => c.id === categoryId); + if (!categoryId || !name || !voterName) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const category = data.categories.find((candidate) => candidate.id === categoryId); if (!category) return res.status(404).json({ error: 'Category not found' }); - const newOption = { - id: uuidv4(), + const newOption = createUserOption({ categoryId, - name: name.trim(), - desc: (desc || '').trim(), - url: url ? url.trim() : null, - lat: lat || null, - lng: lng || null, - addedBy: voterName, + name, + desc, + url, + voterName, + lat, + lng, approved: false, - votes: [], - details: [], - categoryColor: CATEGORY_META[categoryId]?.color || '#888', - }; + }); data.options.push(newOption); saveData(data); @@ -336,8 +210,9 @@ app.post('/api/options', (req, res) => { }); app.post('/api/options/:id/approve', (req, res) => { - const option = data.options.find(o => o.id === req.params.id); + const option = data.options.find((candidate) => candidate.id === req.params.id); if (!option) return res.status(404).json({ error: 'Not found' }); + option.approved = true; saveData(data); broadcast({ type: 'option_approved', option }); @@ -345,9 +220,10 @@ app.post('/api/options/:id/approve', (req, res) => { }); app.delete('/api/options/:id', (req, res) => { - const idx = data.options.findIndex(o => o.id === req.params.id); - if (idx === -1) return res.status(404).json({ error: 'Not found' }); - data.options.splice(idx, 1); + const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id); + if (optionIndex === -1) return res.status(404).json({ error: 'Not found' }); + + data.options.splice(optionIndex, 1); saveData(data); broadcast({ type: 'option_deleted', id: req.params.id }); res.json({ success: true }); @@ -360,9 +236,6 @@ app.post('/api/polls', (req, res) => { res.json({ success: true, pollsOpen: data.pollsOpen }); }); -// ── Yelp Fusion API proxy ─────────────────────────────────── -// API key lives server-side — never exposed to the browser. -// Get your free key at: https://www.yelp.com/developers const YELP_API_KEY = process.env.YELP_API_KEY || ''; app.get('/api/yelp', async (req, res) => { @@ -371,104 +244,112 @@ app.get('/api/yelp', async (req, res) => { if (!YELP_API_KEY) { return res.status(503).json({ - error: 'YELP_API_KEY not configured on server. Add it as an environment variable.' + error: 'YELP_API_KEY not configured on server. Add it as an environment variable.', }); } try { const params = new URLSearchParams({ - term: term + ' in ' + location, + term: `${term} in ${location}`, location: location || 'Los Cabos Mexico', limit: '15', sort_by: 'rating', categories: 'restaurants,nightlife,active,arts,health', }); + const response = await fetch(`https://api.yelp.com/v3/businesses/search?${params}`, { headers: { - 'Authorization': `Bearer ${YELP_API_KEY}`, - 'Accept': 'application/json', - } + Authorization: `Bearer ${YELP_API_KEY}`, + Accept: 'application/json', + }, }); if (!response.ok) { - const errText = await response.text(); - return res.status(response.status).json({ error: `Yelp API error: ${errText}` }); + const errorText = await response.text(); + return res.status(response.status).json({ error: `Yelp API error: ${errorText}` }); } - const data = await response.json(); - // Return only the fields we need to keep payload small - const businesses = (data.businesses || []).map(b => ({ - name: b.name, - image_url: b.image_url, - url: b.url, - rating: b.rating, - price: b.price, - coordinates: b.coordinates, - location: b.location, - categories: b.categories, - display_phone: b.display_phone, - distance: b.distance, + const payload = await response.json(); + const businesses = (payload.businesses || []).map((business) => ({ + name: business.name, + image_url: business.image_url, + url: business.url, + rating: business.rating, + price: business.price, + coordinates: business.coordinates, + location: business.location, + categories: business.categories, + display_phone: business.display_phone, + distance: business.distance, })); - res.json({ businesses, total: data.total }); - } catch (err) { - console.error('Yelp proxy error:', err); + + res.json({ businesses, total: payload.total }); + } catch (error) { + console.error('Yelp proxy error:', error); res.status(500).json({ error: 'Failed to fetch from Yelp' }); } }); -// ── WebSocket ──────────────────────────────────────────────── - wss.on('connection', (ws) => { - ws.send(JSON.stringify({ - type: 'init', - pollsOpen: data.pollsOpen, - categories: data.categories, - options: data.options.filter(o => o.approved), - results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })), - totalVoters: data.voters.length, - })); + ws.send(JSON.stringify(buildRealtimeSnapshot())); ws.on('message', (raw) => { try { const msg = JSON.parse(raw); + if (msg.type === 'vote') { const { optionId, voterName, remove } = msg; if (!voterName || !optionId) return; - if (!data.pollsOpen) { ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); return; } - const option = data.options.find(o => o.id === optionId); + if (!data.pollsOpen) { + ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); + return; + } + + const option = data.options.find((candidate) => candidate.id === optionId); if (!option || !option.approved) return; + if (remove) { - option.votes = option.votes.filter(v => v.name !== voterName); + option.votes = option.votes.filter((vote) => vote.name !== voterName); } else { - const prev = data.options.find(o => o.votes.some(v => v.name === voterName) && o.categoryId === option.categoryId); - if (prev) prev.votes = prev.votes.filter(v => v.name !== voterName); + const previousVote = data.options.find((candidate) => ( + candidate.categoryId === option.categoryId + && candidate.votes.some((vote) => vote.name === voterName) + )); + if (previousVote) { + previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName); + } + option.votes.push({ name: voterName, timestamp: Date.now() }); } - if (!data.voters.find(v => v.name === voterName)) data.voters.push({ name: voterName, joinedAt: Date.now() }); + + if (!data.voters.find((voter) => voter.name === voterName)) { + data.voters.push({ name: voterName, joinedAt: Date.now() }); + } + saveData(data); - broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) }); + broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() }); } else if (msg.type === 'add_option') { const { categoryId, name, desc, url, voterName, lat, lng } = msg; if (!categoryId || !name || !voterName) return; - const newOption = { - id: uuidv4(), + + const newOption = createUserOption({ categoryId, - name: name.trim(), - desc: (desc || '').trim(), - url: url ? url.trim() : null, - lat: lat || null, - lng: lng || null, - addedBy: voterName, + name, + desc, + url, + voterName, + lat, + lng, approved: true, - votes: [], - details: [], - categoryColor: CATEGORY_META[categoryId]?.color || '#888', - }; + }); + data.options.push(newOption); saveData(data); broadcast({ type: 'option_added', option: newOption }); } - } catch (e) { /* ignore malformed */ } + } catch { + // Ignore malformed websocket payloads. + } }); });