feat: add cabo package planning and price watch assets

This commit is contained in:
TopherMayor
2026-04-29 21:29:20 -07:00
parent 88ee723981
commit 8f31b80647
8 changed files with 1951 additions and 312 deletions

View File

@@ -5,7 +5,7 @@ Real-time group voting for the bachelor party — hotels, golf, nightlife, excur
## Quick Start ## Quick Start
```bash ```bash
cd voting_app cd cabo-voting-app
npm install npm install
node server.js node server.js
# → http://localhost:3001 # → http://localhost:3001
@@ -15,6 +15,7 @@ node server.js
- **Real-time WebSocket voting** — all clients update instantly - **Real-time WebSocket voting** — all clients update instantly
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries - **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 - **Add suggestions** — anyone can propose new venues
- **Admin approval** — pending options require approval before going live - **Admin approval** — pending options require approval before going live
- **Responsive** — works on desktop and mobile - **Responsive** — works on desktop and mobile
@@ -22,10 +23,11 @@ node server.js
## Data ## Data
Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel. 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 ## 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. See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap.

908
package-lock.json generated Normal file
View File

@@ -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
}
}
}
}
}

19
package.json Normal file
View File

@@ -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"
}
}

2
price-watch/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
latest-report.md
history.jsonl

View File

@@ -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."
]
}

View File

@@ -333,6 +333,7 @@
--nightlife: #a855f7; --nightlife: #a855f7;
--excursion: #06b6d4; --excursion: #06b6d4;
--itinerary: #fbbf24; --itinerary: #fbbf24;
--budget: #f97316;
} }
/* ── Reset ──────────────────────────────────────────────── */ /* ── Reset ──────────────────────────────────────────────── */
@@ -596,6 +597,29 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.option-link:hover { opacity: 1; text-decoration: underline; } .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 */
.vote-bar-bg { .vote-bar-bg {
@@ -615,6 +639,7 @@
.vote-bar-fill.nightlife { background: var(--nightlife); } .vote-bar-fill.nightlife { background: var(--nightlife); }
.vote-bar-fill.excursion { background: var(--excursion); } .vote-bar-fill.excursion { background: var(--excursion); }
.vote-bar-fill.itinerary { background: var(--itinerary); } .vote-bar-fill.itinerary { background: var(--itinerary); }
.vote-bar-fill.budget { background: var(--budget); }
.voters-row { .voters-row {
margin-top: 5px; margin-top: 5px;
@@ -637,6 +662,117 @@
font-size: 0.65rem; font-size: 0.65rem;
color: var(--text-muted); 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 Option ─────────────────────────────────────────── */
.add-section { .add-section {
@@ -1059,6 +1195,7 @@
<option value="nightlife">🎧 Nightlife</option> <option value="nightlife">🎧 Nightlife</option>
<option value="excursion">🚤 Excursion</option> <option value="excursion">🚤 Excursion</option>
<option value="itinerary">🗺️ Full Itinerary</option> <option value="itinerary">🗺️ Full Itinerary</option>
<option value="budget">💸 Budget Idea</option>
</select> </select>
<button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button> <button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button>
</div> </div>
@@ -1082,6 +1219,8 @@
voterName: localStorage.getItem('cabo_voter_name') || '', voterName: localStorage.getItem('cabo_voter_name') || '',
categories: [], categories: [],
options: [], options: [],
budgetScenarios: [],
priceUpdatedAt: '',
pollsOpen: true, pollsOpen: true,
totalVoters: 0, totalVoters: 0,
wsConnected: false, wsConnected: false,
@@ -1163,6 +1302,8 @@
if (msg.type === 'init') { if (msg.type === 'init') {
state.categories = msg.categories; state.categories = msg.categories;
state.options = msg.options; state.options = msg.options;
state.budgetScenarios = msg.budgetScenarios || [];
state.priceUpdatedAt = msg.priceUpdatedAt || '';
state.pollsOpen = msg.pollsOpen; state.pollsOpen = msg.pollsOpen;
state.totalVoters = msg.totalVoters; state.totalVoters = msg.totalVoters;
renderTabs(); renderTabs();
@@ -1170,7 +1311,7 @@
} else if (msg.type === 'vote_update') { } else if (msg.type === 'vote_update') {
msg.results.forEach(r => { msg.results.forEach(r => {
const opt = state.options.find(o => o.id === r.id); 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(); render();
if (mapInitialized) mapRefreshMarkers(); if (mapInitialized) mapRefreshMarkers();
@@ -1227,6 +1368,12 @@
badge.className = 'polls-badge ' + (state.pollsOpen ? 'open' : 'closed'); 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 ──────────────────────────────────────────── // ── Name modal ────────────────────────────────────────────
function submitName() { function submitName() {
const name = document.getElementById('voterNameInput').value.trim(); const name = document.getElementById('voterNameInput').value.trim();
@@ -1339,7 +1486,17 @@
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank; const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank;
const medalClass = rank === 1 ? 'gold' : rank === 2 ? 'silver' : rank === 3 ? 'bronze' : ''; const medalClass = rank === 1 ? 'gold' : rank === 2 ? 'silver' : rank === 3 ? 'bronze' : '';
const winner = rank === 1 ? 'winner' : ''; 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 ` return `
<div class="results-row"> <div class="results-row">
<div class="results-rank ${medalClass}">${medal}</div> <div class="results-rank ${medalClass}">${medal}</div>
@@ -1369,23 +1526,30 @@
} }
// Sort by votes desc // Sort by votes desc
const sorted = [...opts].sort((a, b) => b.votes.length - a.votes.length); const sorted = [...opts].sort((a, b) => getVoteEntries(b).length - getVoteEntries(a).length);
const maxVotes = sorted[0] ? sorted[0].votes.length : 1; 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 catClass = opt.categoryId;
const votePct = maxVotes > 0 ? (opt.votes.length / maxVotes * 100) : 0; const voteEntries = getVoteEntries(opt);
const hasVoted = state.voterName && opt.votes.some(v => v.name === state.voterName); const votePct = maxVotes > 0 ? (voteEntries.length / maxVotes * 100) : 0;
const voteList = opt.votes.map(v => v.name).join(', '); 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
? `<div class="option-links">${opt.links.map(link => `
<a href="${link.url}" target="_blank" rel="noopener noreferrer" class="option-pill" onclick="event.stopPropagation()">${link.label}</a>
`).join('')}</div>`
: (opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>` : '');
return ` return `
<div class="option-card${hasVoted ? ' voted' : ''}" onclick="toggleVote('${opt.id}')"> <div class="option-card${hasVoted ? ' voted' : ''}" onclick="toggleVote('${opt.id}')">
<div class="option-top"> <div class="option-top">
<div class="option-name">${opt.name}</div> <div class="option-name">${opt.name}</div>
<div class="option-votes">${opt.votes.length} vote${opt.votes.length !== 1 ? 's' : ''}</div> <div class="option-votes">${voteEntries.length} vote${voteEntries.length !== 1 ? 's' : ''}</div>
</div> </div>
${opt.desc ? `<div class="option-desc">${opt.desc}</div>` : ''} ${opt.desc ? `<div class="option-desc">${opt.desc}</div>` : ''}
${opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>` : ''} ${linkPills}
${opt.details && opt.details.length ? ` ${opt.details && opt.details.length ? `
<div class="itin-details">${opt.details.map(d => `<span class="itin-tag">${d}</span>`).join('')}</div> <div class="itin-details">${opt.details.map(d => `<span class="itin-tag">${d}</span>`).join('')}</div>
` : ''} ` : ''}
@@ -1398,6 +1562,39 @@
}).join(''); }).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 `
<section class="budget-board">
<h2>💸 Budget Cheat Sheet</h2>
<p>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.</p>
<div class="budget-stamp">Pricing research last refreshed ${state.priceUpdatedAt || 'recently'}</div>
<div class="budget-grid">
${scenarios.map(scenario => `
<article class="budget-card">
<div class="budget-meta">
<span>${scenario.groupSize} guys</span>
<span class="budget-tier ${scenario.tier.toLowerCase()}">${scenario.tier}</span>
</div>
<h3>${scenario.tier} Track</h3>
<div class="budget-price">$${scenario.perPerson.toLocaleString()}</div>
<div class="budget-total">$${scenario.groupTotal.toLocaleString()} group total</div>
<div class="budget-summary">${scenario.summary}</div>
<ul>${scenario.notes.map(note => `<li>${note}</li>`).join('')}</ul>
</article>
`).join('')}
</div>
</section>
`;
}
// ── Voting ──────────────────────────────────────────────── // ── Voting ────────────────────────────────────────────────
function toggleVote(optionId) { function toggleVote(optionId) {
if (activeTab === 'results') return; // no voting on results tab if (activeTab === 'results') return; // no voting on results tab
@@ -1414,7 +1611,7 @@
const opt = state.options.find(o => o.id === optionId); const opt = state.options.find(o => o.id === optionId);
if (!opt) return; 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 }); wsSend({ type: 'vote', optionId, voterName: state.voterName, remove: alreadyVoted });
showToast(alreadyVoted ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`); showToast(alreadyVoted ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`);

586
seed-data.js Normal file
View File

@@ -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,
};

479
server.js
View File

@@ -5,6 +5,7 @@ const http = require('http');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { CATEGORY_META, buildSeedData, mergeSeedData } = require('./seed-data');
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
@@ -16,241 +17,86 @@ const DATA_FILE = path.join(DATA_DIR, 'votes.json');
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// Admin panel
app.get('/admin', (req, res) => { app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html')); res.sendFile(path.join(__dirname, 'public', 'admin.html'));
}); });
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
// ── Data helpers ──────────────────────────────────────────────
function loadData() { function loadData() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(DATA_FILE)) { if (!fs.existsSync(DATA_FILE)) {
const seed = buildSeedData(); const seed = buildSeedData();
fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2)); fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2));
return seed; 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));
} }
function saveData(data) { return merged;
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) { function broadcast(payload) {
const msg = JSON.stringify(payload); const msg = JSON.stringify(payload);
wss.clients.forEach(client => { wss.clients.forEach((client) => {
if (client.readyState === 1) client.send(msg); if (client.readyState === 1) client.send(msg);
}); });
} }
// ── Category meta (shared with frontend for map colors) ──────── function buildRealtimeSnapshot() {
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() {
return { return {
categories: [ type: 'init',
{ id: 'hotel', name: 'Hotels', emoji: '🏨' }, pollsOpen: data.pollsOpen,
{ id: 'golf', name: 'Golf', emoji: '⛳' }, categories: data.categories,
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' }, options: data.options.filter((option) => option.approved),
{ id: 'excursion', name: 'Excursions', emoji: '🚤' }, results: approvedOptionsWithVoteSummary(),
{ id: 'itinerary', name: 'Itineraries', emoji: '🗺️' }, totalVoters: data.voters.length,
{ id: 'results', name: 'Results', emoji: '🏆' }, budgetScenarios: data.budgetScenarios || [],
], priceUpdatedAt: data.priceUpdatedAt || null,
options: [ };
// Hotels }
{
id: uuidv4(), categoryId: 'hotel', function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) {
name: 'Grand Fiesta Americana', categoryColor: '#3b82f6', return {
desc: 'Premium all-inclusive · Golf packages · 5⭐ · ~$223/night', id: uuidv4(),
url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos', seedKey: null,
lat: 23.0949, lng: -109.7067, categoryId,
addedBy: 'system', approved: true, votes: [] name: name.trim(),
}, desc: (desc || '').trim(),
{ url: url ? url.trim() : null,
id: uuidv4(), categoryId: 'hotel', links: url ? [{ label: 'Website', url: url.trim() }] : [],
name: 'Hotel Riu Palace', categoryColor: '#3b82f6', lat: lat || null,
desc: 'High-energy beachfront · 5⭐ · ~$250/night', lng: lng || null,
url: 'https://www.riu.com/en/hotel/los-cabos/hotel-riu-palace-cabo-san-lucas/', addedBy: voterName,
lat: 23.0731, lng: -109.6987, approved,
addedBy: 'system', approved: true, votes: [] votes: [],
}, details: [],
{ categoryColor: CATEGORY_META[categoryId]?.color || '#888',
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 · $4050 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 $2030/day', 'Flexible dining $200', 'Nightlife $4060', '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,
}; };
} }
let data = loadData(); let data = loadData();
// ── API Routes ───────────────────────────────────────────────
app.get('/api/categories', (req, res) => { app.get('/api/categories', (req, res) => {
res.json(data.categories); res.json(data.categories);
}); });
@@ -258,76 +104,104 @@ app.get('/api/categories', (req, res) => {
app.get('/api/options', (req, res) => { app.get('/api/options', (req, res) => {
const { category, includeUnapproved } = req.query; const { category, includeUnapproved } = req.query;
let options = data.options; 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); res.json(options);
}); });
app.get('/api/results', (req, res) => { app.get('/api/results', (req, res) => {
const results = data.categories.map(cat => ({ const results = data.categories.map((category) => ({
...cat, ...category,
options: data.options options: data.options
.filter(o => o.approved && o.categoryId === cat.id) .filter((option) => option.approved && option.categoryId === category.id)
.map(o => ({ ...o, voteCount: o.votes.length })) .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) => { app.post('/api/vote', (req, res) => {
const { optionId, voterName } = req.body; 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 (!voterName || !optionId) {
if (!option || !option.approved) return res.status(404).json({ error: 'Option not found' }); 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); const option = data.options.find((candidate) => candidate.id === optionId);
if (prevVote) { if (!option || !option.approved) {
prevVote.votes = prevVote.votes.filter(v => v.name !== voterName); 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() }); 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() }); data.voters.push({ name: voterName, joinedAt: Date.now() });
} }
saveData(data); 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 }); res.json({ success: true, voteCount: option.votes.length });
}); });
app.delete('/api/vote/:optionId', (req, res) => { app.delete('/api/vote/:optionId', (req, res) => {
const { voterName } = req.body; 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' }); 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); 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 }); res.json({ success: true });
}); });
app.post('/api/options', (req, res) => { app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, voterName, lat, lng } = req.body; 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' }); if (!category) return res.status(404).json({ error: 'Category not found' });
const newOption = { const newOption = createUserOption({
id: uuidv4(),
categoryId, categoryId,
name: name.trim(), name,
desc: (desc || '').trim(), desc,
url: url ? url.trim() : null, url,
lat: lat || null, voterName,
lng: lng || null, lat,
addedBy: voterName, lng,
approved: false, approved: false,
votes: [], });
details: [],
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
};
data.options.push(newOption); data.options.push(newOption);
saveData(data); saveData(data);
@@ -336,8 +210,9 @@ app.post('/api/options', (req, res) => {
}); });
app.post('/api/options/:id/approve', (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' }); if (!option) return res.status(404).json({ error: 'Not found' });
option.approved = true; option.approved = true;
saveData(data); saveData(data);
broadcast({ type: 'option_approved', option }); broadcast({ type: 'option_approved', option });
@@ -345,9 +220,10 @@ app.post('/api/options/:id/approve', (req, res) => {
}); });
app.delete('/api/options/:id', (req, res) => { app.delete('/api/options/:id', (req, res) => {
const idx = data.options.findIndex(o => o.id === req.params.id); const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Not found' }); if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });
data.options.splice(idx, 1);
data.options.splice(optionIndex, 1);
saveData(data); saveData(data);
broadcast({ type: 'option_deleted', id: req.params.id }); broadcast({ type: 'option_deleted', id: req.params.id });
res.json({ success: true }); res.json({ success: true });
@@ -360,9 +236,6 @@ app.post('/api/polls', (req, res) => {
res.json({ success: true, pollsOpen: data.pollsOpen }); 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 || ''; const YELP_API_KEY = process.env.YELP_API_KEY || '';
app.get('/api/yelp', async (req, res) => { app.get('/api/yelp', async (req, res) => {
@@ -371,104 +244,112 @@ app.get('/api/yelp', async (req, res) => {
if (!YELP_API_KEY) { if (!YELP_API_KEY) {
return res.status(503).json({ 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 { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
term: term + ' in ' + location, term: `${term} in ${location}`,
location: location || 'Los Cabos Mexico', location: location || 'Los Cabos Mexico',
limit: '15', limit: '15',
sort_by: 'rating', sort_by: 'rating',
categories: 'restaurants,nightlife,active,arts,health', categories: 'restaurants,nightlife,active,arts,health',
}); });
const response = await fetch(`https://api.yelp.com/v3/businesses/search?${params}`, { const response = await fetch(`https://api.yelp.com/v3/businesses/search?${params}`, {
headers: { headers: {
'Authorization': `Bearer ${YELP_API_KEY}`, Authorization: `Bearer ${YELP_API_KEY}`,
'Accept': 'application/json', Accept: 'application/json',
} },
}); });
if (!response.ok) { if (!response.ok) {
const errText = await response.text(); const errorText = await response.text();
return res.status(response.status).json({ error: `Yelp API error: ${errText}` }); return res.status(response.status).json({ error: `Yelp API error: ${errorText}` });
} }
const data = await response.json(); const payload = await response.json();
// Return only the fields we need to keep payload small const businesses = (payload.businesses || []).map((business) => ({
const businesses = (data.businesses || []).map(b => ({ name: business.name,
name: b.name, image_url: business.image_url,
image_url: b.image_url, url: business.url,
url: b.url, rating: business.rating,
rating: b.rating, price: business.price,
price: b.price, coordinates: business.coordinates,
coordinates: b.coordinates, location: business.location,
location: b.location, categories: business.categories,
categories: b.categories, display_phone: business.display_phone,
display_phone: b.display_phone, distance: business.distance,
distance: b.distance,
})); }));
res.json({ businesses, total: data.total });
} catch (err) { res.json({ businesses, total: payload.total });
console.error('Yelp proxy error:', err); } catch (error) {
console.error('Yelp proxy error:', error);
res.status(500).json({ error: 'Failed to fetch from Yelp' }); res.status(500).json({ error: 'Failed to fetch from Yelp' });
} }
}); });
// ── WebSocket ────────────────────────────────────────────────
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
ws.send(JSON.stringify({ ws.send(JSON.stringify(buildRealtimeSnapshot()));
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.on('message', (raw) => { ws.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw); const msg = JSON.parse(raw);
if (msg.type === 'vote') { if (msg.type === 'vote') {
const { optionId, voterName, remove } = msg; const { optionId, voterName, remove } = msg;
if (!voterName || !optionId) return; if (!voterName || !optionId) return;
if (!data.pollsOpen) { ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); return; } if (!data.pollsOpen) {
const option = data.options.find(o => o.id === optionId); 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 (!option || !option.approved) return;
if (remove) { if (remove) {
option.votes = option.votes.filter(v => v.name !== voterName); option.votes = option.votes.filter((vote) => vote.name !== voterName);
} else { } else {
const prev = data.options.find(o => o.votes.some(v => v.name === voterName) && o.categoryId === option.categoryId); const previousVote = data.options.find((candidate) => (
if (prev) prev.votes = prev.votes.filter(v => v.name !== voterName); 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() }); 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); 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') { } else if (msg.type === 'add_option') {
const { categoryId, name, desc, url, voterName, lat, lng } = msg; const { categoryId, name, desc, url, voterName, lat, lng } = msg;
if (!categoryId || !name || !voterName) return; if (!categoryId || !name || !voterName) return;
const newOption = {
id: uuidv4(), const newOption = createUserOption({
categoryId, categoryId,
name: name.trim(), name,
desc: (desc || '').trim(), desc,
url: url ? url.trim() : null, url,
lat: lat || null, voterName,
lng: lng || null, lat,
addedBy: voterName, lng,
approved: true, approved: true,
votes: [], });
details: [],
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
};
data.options.push(newOption); data.options.push(newOption);
saveData(data); saveData(data);
broadcast({ type: 'option_added', option: newOption }); broadcast({ type: 'option_added', option: newOption });
} }
} catch (e) { /* ignore malformed */ } } catch {
// Ignore malformed websocket payloads.
}
}); });
}); });