feat: add cabo package planning and price watch assets
This commit is contained in:
@@ -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
908
package-lock.json
generated
Normal 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
19
package.json
Normal 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
2
price-watch/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
latest-report.md
|
||||||
|
history.jsonl
|
||||||
44
price-watch/watch-targets.json
Normal file
44
price-watch/watch-targets.json
Normal 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
586
seed-data.js
Normal 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
479
server.js
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveData(data) {
|
function saveData(nextData) {
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
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 · $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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user